diff --git a/openapi/openapi.go b/openapi/openapi.go index dcf79e13..874c098c 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -63,6 +63,12 @@ func Load(appConfig config.Config) (*OpenAPI, error) { return nil, err } + // Load user configurations + err = user.Load(appConfig) + if err != nil { + return nil, err + } + // Create the OpenAPI server Server = &OpenAPI{Config: &config, OAuth: oauthService} return Server, nil diff --git a/openapi/tests/user/login_config_test.go b/openapi/tests/user/login_config_test.go new file mode 100644 index 00000000..2c6e2488 --- /dev/null +++ b/openapi/tests/user/login_config_test.go @@ -0,0 +1,168 @@ +package user_test + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" + "github.com/yaoapp/yao/openapi/user" +) + +func TestUserLoginConfig(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client first (needed for user.Load validation) + testClient := testutils.RegisterTestClient(t, "User Config Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Test API endpoints + testCases := []struct { + name string + endpoint string + expectCode int + }{ + {"get config without locale", "/user/login", 200}, + {"get config with en locale", "/user/login?locale=en", 200}, + {"get config with zh-cn locale", "/user/login?locale=zh-cn", 200}, + {"get config with invalid locale", "/user/login?locale=invalid", 200}, // should fallback to default + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + tc.endpoint + resp, err := http.Get(requestURL) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode) + + if resp.StatusCode == 200 { + // Parse response body + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + var config user.Config + err = json.Unmarshal(body, &config) + assert.NoError(t, err, "Should parse JSON response") + + t.Logf("API response for %s: %s", tc.endpoint, config.Title) + + // Verify it's public config (no sensitive data) + if config.ThirdParty != nil && config.ThirdParty.Providers != nil { + for _, provider := range config.ThirdParty.Providers { + // Check that sensitive OAuth fields are removed from API response + assert.Empty(t, provider.ClientID, "Client ID should be empty in API response") + assert.Empty(t, provider.ClientSecret, "Client secret should be empty in API response") + assert.Nil(t, provider.ClientSecretGenerator, "Client secret generator should be nil in API response") + assert.Empty(t, provider.Scopes, "Scopes should be empty in API response") + assert.Nil(t, provider.Endpoints, "Endpoints should be nil in API response") + assert.Empty(t, provider.Mapping, "Mapping should be empty in API response") + + // Check that display fields are preserved in API response + assert.NotEmpty(t, provider.ID, "Provider ID should be preserved in API response") + assert.NotEmpty(t, provider.Title, "Provider title should be preserved in API response") + } + } + + // Verify captcha sensitive data is removed from API response + if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil { + _, hasSecret := config.Form.Captcha.Options["secret"] + assert.False(t, hasSecret, "Captcha secret should be removed from API response") + } + } + } + }) + } +} + +func TestUserLoginConfigLoad(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + _ = serverURL // Server URL not needed for this test + + // Test loading user configurations + err := user.Load(config.Conf) + assert.NoError(t, err, "user.Load should succeed") + + // Test that we can get public config + publicConfig := user.GetPublicConfig("") + if publicConfig != nil { + t.Logf("Public config loaded with title: %s", publicConfig.Title) + assert.IsType(t, &user.Config{}, publicConfig, "Should return correct config type") + } else { + t.Log("No public config found") + } +} + +func TestUserLoginConfigStructure(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + _ = serverURL // Server URL not needed for this test + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Get a config to test structure + config := user.GetPublicConfig("") + if config != nil { + t.Logf("Config loaded successfully with title: %s", config.Title) + + // Verify config structure is valid + assert.IsType(t, &user.Config{}, config, "Should return correct config type") + + // Test new configuration fields + assert.IsType(t, "", config.ClientID, "ClientID should be string") + assert.IsType(t, "", config.ClientSecret, "ClientSecret should be string") + assert.IsType(t, false, config.Default, "Default should be boolean") + t.Logf("Config has ClientID: %t, ClientSecret: %t, Default: %t", + config.ClientID != "", config.ClientSecret != "", config.Default) + + // Test form configuration + if config.Form != nil { + t.Logf("Form configuration found") + if config.Form.Username != nil { + assert.IsType(t, []string{}, config.Form.Username.Fields, "Username fields should be string slice") + } + if config.Form.Captcha != nil { + assert.IsType(t, map[string]interface{}{}, config.Form.Captcha.Options, "Captcha options should be map") + } + } + + // Test third party configuration + if config.ThirdParty != nil { + t.Logf("Third party configuration found with %d providers", len(config.ThirdParty.Providers)) + if config.ThirdParty.Providers != nil { + assert.IsType(t, []*user.Provider{}, config.ThirdParty.Providers, "Providers should be slice of Provider pointers") + for i, provider := range config.ThirdParty.Providers { + t.Logf("Provider %d: %s", i, provider.ID) + + // In the new structure, ThirdParty providers only contain display information + // Sensitive configuration data is stored separately in the global providers map + assert.NotEmpty(t, provider.ID, "Provider ID should not be empty") + assert.NotEmpty(t, provider.Title, "Provider title should not be empty") + } + } + } + } else { + t.Log("No user configuration found") + } +} diff --git a/openapi/tests/user/login_test.go b/openapi/tests/user/login_test.go new file mode 100644 index 00000000..d4591361 --- /dev/null +++ b/openapi/tests/user/login_test.go @@ -0,0 +1,182 @@ +package user_test + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +func TestUserLogin(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client first (needed for user.Load validation) + testClient := testutils.RegisterTestClient(t, "User Login Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Test login endpoint (currently empty implementation) + testCases := []struct { + name string + method string + endpoint string + body map[string]interface{} + expectCode int + }{ + { + "post login without credentials", + "POST", + "/user/login", + map[string]interface{}{}, + 200, // Currently empty implementation, may change when implemented + }, + { + "post login with credentials", + "POST", + "/user/login", + map[string]interface{}{ + "username": "testuser", + "password": "testpass", + }, + 200, // Currently empty implementation, may change when implemented + }, + { + "post login with email", + "POST", + "/user/login", + map[string]interface{}{ + "email": "test@example.com", + "password": "testpass", + }, + 200, // Currently empty implementation, may change when implemented + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + tc.endpoint + + // Prepare request body + var req *http.Request + var err error + + if tc.method == "POST" { + bodyBytes, _ := json.Marshal(tc.body) + req, err = http.NewRequest(tc.method, requestURL, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else { + req, err = http.NewRequest(tc.method, requestURL, nil) + } + + assert.NoError(t, err, "Should create HTTP request") + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode) + + t.Logf("Login test %s: status=%d", tc.name, resp.StatusCode) + + // Note: Since login is currently not implemented (empty function), + // we can't test actual login functionality yet. + // This test serves as a placeholder for when login is implemented. + } + }) + } +} + +func TestUserLoginValidation(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Test various login validation scenarios + // Note: These tests are prepared for when login validation is implemented + testCases := []struct { + name string + body map[string]interface{} + expected string // Expected behavior description + }{ + { + "empty credentials", + map[string]interface{}{}, + "Should handle empty credentials gracefully", + }, + { + "missing password", + map[string]interface{}{ + "username": "testuser", + }, + "Should handle missing password", + }, + { + "missing username", + map[string]interface{}{ + "password": "testpass", + }, + "Should handle missing username", + }, + { + "invalid json format", + nil, // Will send invalid JSON + "Should handle invalid JSON format", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/login" + + var req *http.Request + var err error + + if tc.body == nil { + // Send invalid JSON + req, err = http.NewRequest("POST", requestURL, bytes.NewBufferString("invalid json")) + } else { + bodyBytes, _ := json.Marshal(tc.body) + req, err = http.NewRequest("POST", requestURL, bytes.NewBuffer(bodyBytes)) + } + + req.Header.Set("Content-Type", "application/json") + assert.NoError(t, err, "Should create HTTP request") + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + t.Logf("Validation test %s: status=%d, expected=%s", tc.name, resp.StatusCode, tc.expected) + + // Note: Since login validation is not implemented yet, + // we can't assert specific status codes. + // These tests will be updated when login is implemented. + } + }) + } +} diff --git a/openapi/tests/user/oauth_authorize_test.go b/openapi/tests/user/oauth_authorize_test.go new file mode 100644 index 00000000..7f8403af --- /dev/null +++ b/openapi/tests/user/oauth_authorize_test.go @@ -0,0 +1,264 @@ +package user_test + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +func TestUserOAuthAuthorizationURL(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client first (needed for user.Load validation) + testClient := testutils.RegisterTestClient(t, "User OAuth Authorize Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Test OAuth authorization URL endpoints + // Note: These should return 200 when OAuth client credentials are properly configured + // (which they are in this test environment). Only nonexistent providers should return 404. + testCases := []struct { + name string + provider string + query string + expectCode int + expectErrorMsg string + }{ + {"get google oauth url", "google", "", 200, ""}, + {"get microsoft oauth url", "microsoft", "", 200, ""}, + {"get apple oauth url", "apple", "", 200, ""}, + {"get github oauth url", "github", "", 200, ""}, + {"get oauth url with redirect_uri", "google", "?redirect_uri=https://example.com/callback", 200, ""}, + {"get oauth url with state", "google", "?state=test-state-123", 200, ""}, + {"get oauth url for nonexistent provider", "nonexistent", "", 404, "Failed to get provider"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/oauth/" + tc.provider + "/authorize" + tc.query + resp, err := http.Get(requestURL) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode) + + // Parse response body + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + t.Logf("Response for %s: status=%d, body=%s", tc.provider, resp.StatusCode, string(body)) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + if tc.expectCode == 200 { + // Success case - should have authorization_url + if authURL, hasAuthURL := response["authorization_url"]; hasAuthURL { + authURLStr, ok := authURL.(string) + assert.True(t, ok, "authorization_url should be string") + assert.NotEmpty(t, authURLStr, "authorization_url should not be empty") + t.Logf("Authorization URL generated successfully for %s", tc.provider) + + // Verify the URL contains expected OAuth parameters + assert.Contains(t, authURLStr, "client_id=", "Authorization URL should contain client_id") + assert.Contains(t, authURLStr, "response_type=code", "Authorization URL should contain response_type=code") + assert.Contains(t, authURLStr, "redirect_uri=", "Authorization URL should contain redirect_uri") + assert.Contains(t, authURLStr, "state=", "Authorization URL should contain state") + + // Check for state in response + if state, hasState := response["state"]; hasState { + stateStr, ok := state.(string) + assert.True(t, ok, "state should be string") + assert.NotEmpty(t, stateStr, "state should not be empty") + t.Logf("State generated: %s", stateStr) + } + + // Check for warnings (optional) + if warnings, hasWarnings := response["warnings"]; hasWarnings { + warningsSlice, ok := warnings.([]interface{}) + if ok && len(warningsSlice) > 0 { + t.Logf("Warnings: %v", warningsSlice) + } + } + } else { + t.Errorf("Success response should contain authorization_url field") + } + } else { + // Error case - should have error fields + if errorDescription, hasError := response["error_description"]; hasError { + errorDescStr, ok := errorDescription.(string) + assert.True(t, ok, "error_description should be string") + if tc.expectErrorMsg != "" { + assert.Contains(t, errorDescStr, tc.expectErrorMsg, "Error message should contain expected text") + } + } else { + t.Errorf("Error response should contain error_description field") + } + + // Verify error code is present + if errorCode, hasErrorCode := response["error"]; hasErrorCode { + assert.Equal(t, "invalid_request", errorCode, "Error code should be invalid_request") + } else { + t.Errorf("Error response should contain error field") + } + } + } + }) + } +} + +func TestUserOAuthAuthorizationURLParameters(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client first (needed for user.Load validation) + testClient := testutils.RegisterTestClient(t, "User OAuth URL Params Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Test various OAuth parameters + testCases := []struct { + name string + provider string + redirectURI string + state string + expectCode int + }{ + { + "with custom redirect_uri", + "google", + "https://myapp.example.com/callback", + "", + 200, + }, + { + "with custom state", + "google", + "", + "my-custom-state-12345", + 200, + }, + { + "with both redirect_uri and state", + "google", + "https://myapp.example.com/callback", + "my-custom-state-12345", + 200, + }, + { + "with UUID state format", + "google", + "", + "550e8400-e29b-41d4-a716-446655440000", + 200, + }, + { + "with non-UUID state format", + "google", + "", + "simple-state", + 200, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Build query parameters + query := "" + params := []string{} + if tc.redirectURI != "" { + params = append(params, "redirect_uri="+tc.redirectURI) + } + if tc.state != "" { + params = append(params, "state="+tc.state) + } + if len(params) > 0 { + query = "?" + strings.Join(params, "&") + } + + requestURL := serverURL + baseURL + "/user/oauth/" + tc.provider + "/authorize" + query + resp, err := http.Get(requestURL) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode) + + // Parse response body + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + if tc.expectCode == 200 { + // Verify authorization URL is generated + if authURL, hasAuthURL := response["authorization_url"]; hasAuthURL { + authURLStr, ok := authURL.(string) + assert.True(t, ok, "authorization_url should be string") + assert.NotEmpty(t, authURLStr, "authorization_url should not be empty") + + // Verify custom parameters are included in the URL + if tc.redirectURI != "" { + // Parse the authorization URL and check parameters + parsedURL, err := url.Parse(authURLStr) + assert.NoError(t, err, "Authorization URL should be valid") + + // Check if redirect_uri parameter matches + redirectURI := parsedURL.Query().Get("redirect_uri") + assert.Equal(t, tc.redirectURI, redirectURI, "Authorization URL should contain custom redirect_uri") + } + + // Verify state parameter + if state, hasState := response["state"]; hasState { + stateStr, ok := state.(string) + assert.True(t, ok, "state should be string") + assert.NotEmpty(t, stateStr, "state should not be empty") + + if tc.state != "" { + assert.Equal(t, tc.state, stateStr, "State should match provided state") + } + + // Check for warnings about non-UUID state + if warnings, hasWarnings := response["warnings"]; hasWarnings { + warningsSlice, ok := warnings.([]interface{}) + if ok { + t.Logf("Warnings for state '%s': %v", stateStr, warningsSlice) + } + } + } + + t.Logf("Test %s passed: URL=%s", tc.name, authURLStr) + } + } + } + }) + } +} diff --git a/openapi/tests/user/oauth_callback_test.go b/openapi/tests/user/oauth_callback_test.go new file mode 100644 index 00000000..a36df146 --- /dev/null +++ b/openapi/tests/user/oauth_callback_test.go @@ -0,0 +1,294 @@ +package user_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +func TestUserOAuthCallback(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client first (needed for user.Load validation) + testClient := testutils.RegisterTestClient(t, "User OAuth Callback Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Note: OAuth callback testing requires a complex setup with valid OAuth state + // and authorization codes. For now, we test the endpoint accessibility and + // basic error handling. + + testCases := []struct { + name string + provider string + method string + body map[string]interface{} + expectCode int + expectMsg string + }{ + { + "callback without parameters", + "google", + "POST", + map[string]interface{}{}, + 400, // Should return bad request for missing parameters + "State is required", + }, + { + "callback with invalid state", + "google", + "POST", + map[string]interface{}{ + "code": "test-auth-code", + "state": "invalid-state", + }, + 400, // Should return bad request for invalid state + "Invalid state", + }, + { + "callback for nonexistent provider", + "nonexistent", + "POST", + map[string]interface{}{ + "code": "test-auth-code", + "state": "test-state", + }, + 404, // Should return not found for nonexistent provider + "Failed to get provider", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/oauth/" + tc.provider + "/callback" + + // Prepare request body + bodyBytes, _ := json.Marshal(tc.body) + req, err := http.NewRequest(tc.method, requestURL, bytes.NewBuffer(bodyBytes)) + assert.NoError(t, err, "Should create HTTP request") + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + + t.Logf("OAuth callback test %s: status=%d", tc.name, resp.StatusCode) + + // Note: The exact status codes may vary based on implementation + // These tests verify the endpoint is accessible and handles basic errors + assert.True(t, resp.StatusCode >= 400 || resp.StatusCode < 300, + "Should return either success or client/server error") + + // For error responses, try to parse error message + if resp.StatusCode >= 400 { + body, err := io.ReadAll(resp.Body) + if err == nil { + var response map[string]interface{} + if json.Unmarshal(body, &response) == nil { + if errorDesc, hasError := response["error_description"]; hasError { + errorDescStr, ok := errorDesc.(string) + if ok && tc.expectMsg != "" { + t.Logf("Error message: %s", errorDescStr) + // Note: Exact error message matching may vary + // We just verify the endpoint responds with error details + } + } + } + } + } + } + }) + } +} + +func TestUserOAuthCallbackPrepare(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client first (needed for user.Load validation) + testClient := testutils.RegisterTestClient(t, "User OAuth Prepare Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Test OAuth callback prepare endpoint (form_post mode) + testCases := []struct { + name string + provider string + formData map[string]string + expectCode int + }{ + { + "prepare without parameters", + "apple", // Apple typically uses form_post mode + map[string]string{}, + 500, // Should return error for missing parameters + }, + { + "prepare with code and state", + "apple", + map[string]string{ + "code": "test-auth-code", + "state": "test-state", + }, + 500, // Will fail due to invalid state, but endpoint should be accessible + }, + { + "prepare with user info", + "apple", + map[string]string{ + "code": "test-auth-code", + "state": "test-state", + "user": `{"name":{"firstName":"John","lastName":"Doe"},"email":"john@example.com"}`, + }, + 500, // Will fail due to invalid state, but endpoint should be accessible + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/oauth/" + tc.provider + "/authorize/prepare" + + // Prepare form data + formData := url.Values{} + for key, value := range tc.formData { + formData.Set(key, value) + } + + req, err := http.NewRequest("POST", requestURL, strings.NewReader(formData.Encode())) + assert.NoError(t, err, "Should create HTTP request") + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Don't follow redirects, we want to test the response + return http.ErrUseLastResponse + }, + } + + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + + t.Logf("OAuth prepare test %s: status=%d", tc.name, resp.StatusCode) + + // The prepare endpoint may redirect or return errors + // We just verify it's accessible and responds appropriately + assert.True(t, resp.StatusCode == 302 || resp.StatusCode >= 400, + "Should return redirect or error response") + + // If it's a redirect, check the location header + if resp.StatusCode == 302 { + location := resp.Header.Get("Location") + if location != "" { + t.Logf("Redirect location: %s", location) + assert.Contains(t, location, "code=", "Redirect should contain code parameter") + assert.Contains(t, location, "state=", "Redirect should contain state parameter") + } + } + } + }) + } +} + +func TestUserOAuthProviderValidation(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client first (needed for user.Load validation) + testClient := testutils.RegisterTestClient(t, "User OAuth Validation Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Note: user.Load is automatically called by openapi.Load in testutils.Prepare + + // Test provider validation + providers := []string{"google", "microsoft", "apple", "github", "nonexistent"} + + for _, provider := range providers { + t.Run("provider_"+provider, func(t *testing.T) { + // Test authorize endpoint + authorizeURL := serverURL + baseURL + "/user/oauth/" + provider + "/authorize" + resp, err := http.Get(authorizeURL) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + + if provider == "nonexistent" { + assert.Equal(t, 404, resp.StatusCode, "Nonexistent provider should return 404") + } else { + // Known providers should return 200 or other valid response + assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 500, + "Known provider should return 200 or 500 (if not configured)") + } + + t.Logf("Provider %s authorize endpoint: status=%d", provider, resp.StatusCode) + } + + // Test callback endpoint + callbackURL := serverURL + baseURL + "/user/oauth/" + provider + "/callback" + bodyBytes, _ := json.Marshal(map[string]interface{}{ + "code": "test-code", + "state": "test-state", + }) + req, err := http.NewRequest("POST", callbackURL, bytes.NewBuffer(bodyBytes)) + assert.NoError(t, err, "Should create HTTP request") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err = client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + + if provider == "nonexistent" { + assert.Equal(t, 404, resp.StatusCode, "Nonexistent provider should return 404") + } else { + // Known providers should return 400 (bad request due to invalid state) or other error + assert.True(t, resp.StatusCode >= 400, "Known provider should return error for invalid request") + } + + t.Logf("Provider %s callback endpoint: status=%d", provider, resp.StatusCode) + } + }) + } +} diff --git a/openapi/user/config.go b/openapi/user/config.go new file mode 100644 index 00000000..b0de582d --- /dev/null +++ b/openapi/user/config.go @@ -0,0 +1,419 @@ +package user + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/yaoapp/gou/application" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Global variables to store loaded configurations +var ( + // Client config + yaoClientConfig *YaoClientConfig + + // Full configurations with sensitive data (for backend use) + fullConfigs = make(map[string]*Config) + // Public configurations without sensitive data (for frontend use) + publicConfigs = make(map[string]*Config) + // Global providers map (decoupled from locale-specific configs) + providers = make(map[string]*Provider) + // Default configuration (marked with default: true) + defaultConfig *Config + // Mutex for thread safety + configMutex sync.RWMutex +) + +// Load loads all signin configurations from the openapi/user directory +func Load(appConfig config.Config) error { + configMutex.Lock() + defer configMutex.Unlock() + + // Clear existing configurations + fullConfigs = make(map[string]*Config) + publicConfigs = make(map[string]*Config) + providers = make(map[string]*Provider) + defaultConfig = nil + + // Load signin configurations + err := loadSigninConfigs(appConfig.Root) + if err != nil { + return fmt.Errorf("failed to load signin configs: %v", err) + } + + // Load providers first + err = loadProviders(appConfig.Root) + if err != nil { + return fmt.Errorf("failed to load providers: %v", err) + } + + // Load client config + err = loadClientConfig() + if err != nil { + return fmt.Errorf("failed to load client config: %v", err) + } + + return nil +} + +// loadClientConfig loads the client config from the openapi/user/client.yao file +func loadClientConfig() error { + // Check if client config exists + exists, err := application.App.Exists("openapi/user/client.yao") + if err != nil { + return fmt.Errorf("failed to check if client config exists: %v", err) + } + + if !exists { + return fmt.Errorf("client config not found") + } + + // Read client config + clientConfigRaw, err := application.App.Read("openapi/user/client.yao") + if err != nil { + return fmt.Errorf("failed to read client config: %v", err) + } + + var clientConfig YaoClientConfig + err = application.Parse("openapi/user/client.yao", clientConfigRaw, &clientConfig) + if err != nil { + return fmt.Errorf("failed to parse client config: %v", err) + } + + // Process ENV variables in client config + clientConfig.ClientID = replaceENVVar(clientConfig.ClientID) + clientConfig.ClientSecret = replaceENVVar(clientConfig.ClientSecret) + + // Validate client config + err = validateClientConfig(&clientConfig) + if err != nil { + return fmt.Errorf("failed to validate client config: %v", err) + } + + yaoClientConfig = &clientConfig + return nil +} + +// validateClientConfig validates the client config +func validateClientConfig(clientConfig *YaoClientConfig) error { + + // Validate client ID + err := oauth.OAuth.ValidateClientID(clientConfig.ClientID) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Validate client is registered + c := oauth.OAuth.GetClientProvider() + _, err = c.GetClientByID(ctx, clientConfig.ClientID) + if err != nil { + // If client is not registered, register it + if strings.Contains(err.Error(), "Client not found") { + yaoClientConfig, err = registerClient(clientConfig.ClientID) + if err != nil { + return fmt.Errorf("failed to register client: %v", err) + } + return nil + } + return fmt.Errorf("failed to get client: %v", err) + } + + return nil +} + +// registerClient registers the client config with the OAuth server +func registerClient(clientID string) (*YaoClientConfig, error) { + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Register client + response, err := oauth.OAuth.DynamicClientRegistration(ctx, &types.DynamicClientRegistrationRequest{ + ClientID: clientID, + ClientName: "Yao OpenAPI Client", + ResponseTypes: []string{"code"}, + GrantTypes: []string{"client_credentials"}, + ApplicationType: types.ApplicationTypeWeb, + }) + if err != nil { + return nil, fmt.Errorf("failed to create client: %v", err) + } + + var clientConfig *YaoClientConfig = &YaoClientConfig{} + clientConfig.ClientID = response.ClientID + clientConfig.ClientSecret = response.ClientSecret + clientConfig.ExpiresIn = 3600 * 24 // 24 hours + clientConfig.RefreshTokenExpiresIn = 3600 * 24 * 30 // 30 days + clientConfig.Scopes = []string{"openid", "profile", "email"} + return clientConfig, nil +} + +// loadProviders loads all provider configurations from the openapi/user/providers directory +func loadProviders(_ string) error { + // Use Walk to find all provider files in the signin/providers directory + err := application.App.Walk("openapi/user/providers", func(root, filename string, isdir bool) error { + if isdir { + return nil + } + + // Only process .yao files + if !strings.HasSuffix(filename, ".yao") { + return nil + } + + // Skip client.yao file + if filename == "client.yao" { + return nil + } + + // Extract provider ID from filename (basename without extension) + baseName := filepath.Base(filename) + providerID := strings.TrimSuffix(baseName, ".yao") + + // Read provider configuration + configRaw, err := application.App.Read(filename) + if err != nil { + return fmt.Errorf("failed to read provider config %s: %v", filename, err) + } + + // Parse the provider configuration + var provider Provider + err = application.Parse(filename, configRaw, &provider) + if err != nil { + return fmt.Errorf("failed to parse provider config %s: %v", filename, err) + } + + // Set the provider ID + provider.ID = providerID + + // Process ENV variables in the provider configuration + provider.ClientID = replaceENVVar(provider.ClientID) + provider.ClientSecret = replaceENVVar(provider.ClientSecret) + + // Store the provider globally + providers[providerID] = &provider + + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk providers directory: %v", err) + } + + return nil +} + +// loadSigninConfigs loads all signin configurations from the openapi/user directory +func loadSigninConfigs(_ string) error { + // Use Walk to find all configuration files in the signin directory + err := application.App.Walk("openapi/user", func(root, filename string, isdir bool) error { + if isdir { + return nil + } + + // Only process .yao files + if !strings.HasSuffix(filename, ".yao") { + return nil + } + + // Skip providers directory and client.yao file + if strings.Contains(filename, "providers/") || filepath.Base(filename) == "client.yao" { + return nil + } + + // Extract locale from filename (basename without extension) + baseName := filepath.Base(filename) + locale := strings.TrimSuffix(baseName, ".yao") + + // Read configuration + configRaw, err := application.App.Read(filename) + if err != nil { + return fmt.Errorf("failed to read config %s: %v", filename, err) + } + + // Parse the configuration + var config Config + err = application.Parse(filename, configRaw, &config) + if err != nil { + return fmt.Errorf("failed to parse config %s: %v", filename, err) + } + + // Process ENV variables in the configuration + config.ClientID = replaceENVVar(config.ClientID) + config.ClientSecret = replaceENVVar(config.ClientSecret) + + // Store full configuration + fullConfigs[locale] = &config + + // Create public configuration (without sensitive data) + publicConfig := config + publicConfig.ClientSecret = "" // Remove sensitive data + + // Remove captcha secret from public config + if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil { + // Create a copy of captcha options without the secret + captchaOptions := make(map[string]interface{}) + for k, v := range publicConfig.Form.Captcha.Options { + if k != "secret" { + captchaOptions[k] = v + } + } + publicConfig.Form.Captcha.Options = captchaOptions + } + + publicConfigs[locale] = &publicConfig + + // Set as default if marked + if config.Default { + defaultConfig = &config + } + + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk signin directory: %v", err) + } + + return nil +} + +// GetPublicConfig returns the public configuration for a given locale +func GetPublicConfig(locale string) *Config { + configMutex.RLock() + defer configMutex.RUnlock() + + // Try to get the specific locale configuration + if config, exists := publicConfigs[locale]; exists { + return config + } + + // Fallback to default configuration + if defaultConfig != nil { + // Create a copy of default config for public use + publicDefault := *defaultConfig + publicDefault.ClientSecret = "" // Remove sensitive data + + // Remove captcha secret from public config + if publicDefault.Form != nil && publicDefault.Form.Captcha != nil && publicDefault.Form.Captcha.Options != nil { + // Create a copy of captcha options without the secret + captchaOptions := make(map[string]interface{}) + for k, v := range publicDefault.Form.Captcha.Options { + if k != "secret" { + captchaOptions[k] = v + } + } + publicDefault.Form.Captcha.Options = captchaOptions + } + + return &publicDefault + } + + // If no default, try to get any available configuration + for _, config := range publicConfigs { + return config + } + + return nil +} + +// GetProvider returns a provider by ID +func GetProvider(providerID string) (*Provider, error) { + configMutex.RLock() + defer configMutex.RUnlock() + + provider, exists := providers[providerID] + if !exists { + return nil, fmt.Errorf("provider '%s' not found", providerID) + } + + return provider, nil +} + +// GetYaoClientConfig returns the current yaoClientConfig +func GetYaoClientConfig() *YaoClientConfig { + configMutex.RLock() + defer configMutex.RUnlock() + return yaoClientConfig +} + +// replaceENVVar replaces environment variables in a string +func replaceENVVar(value string) string { + if value == "" { + return value + } + + // Replace ${ENV_VAR} or $ENV.VAR patterns + re := regexp.MustCompile(`\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_.]*)`) + return re.ReplaceAllStringFunc(value, func(match string) string { + var envVar string + if strings.HasPrefix(match, "${") { + // Extract from ${VAR} format + envVar = match[2 : len(match)-1] + } else { + // Extract from $VAR format, remove $ENV. prefix if present + envVar = match[1:] + envVar = strings.TrimPrefix(envVar, "ENV.") + } + + if envValue := os.Getenv(envVar); envValue != "" { + return envValue + } + return match // Return original if env var not found + }) +} + +// normalizeDuration normalizes various duration formats to Go's time.ParseDuration format +func normalizeDuration(expiresIn string) (string, error) { + if expiresIn == "" { + return "", fmt.Errorf("empty duration") + } + + // Common patterns and their conversions + patterns := map[string]func(int) string{ + "s": func(n int) string { return fmt.Sprintf("%ds", n) }, // seconds + "m": func(n int) string { return fmt.Sprintf("%dm", n) }, // minutes + "h": func(n int) string { return fmt.Sprintf("%dh", n) }, // hours + } + + // Extract number and unit using regex + re := regexp.MustCompile(`^(\d+)(\w+)$`) + matches := re.FindStringSubmatch(expiresIn) + + if len(matches) != 3 { + return "", fmt.Errorf("invalid duration format: %s", expiresIn) + } + + number, err := strconv.Atoi(matches[1]) + if err != nil { + return "", fmt.Errorf("invalid number in duration: %s", matches[1]) + } + + unit := matches[2] + converter, exists := patterns[unit] + if !exists { + return "", fmt.Errorf("unsupported time unit: %s", unit) + } + + normalized := converter(number) + + // Validate the normalized duration + if _, err := time.ParseDuration(normalized); err != nil { + return "", fmt.Errorf("failed to create valid duration: %v", err) + } + + return normalized, nil +} diff --git a/openapi/user/login.go b/openapi/user/login.go new file mode 100644 index 00000000..7e47cbc9 --- /dev/null +++ b/openapi/user/login.go @@ -0,0 +1,181 @@ +package user + +import ( + "context" + "strings" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/session" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/oauth/providers/user" + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/openapi/utils" +) + +// getLoginConfig is the handler for get login configuration (mapped from /signin) +func getLoginConfig(c *gin.Context) { + // Get locale from query parameter (optional) + locale := c.Query("locale") + + // Get public configuration for the specified locale + config := GetPublicConfig(locale) + + // Set session id if not exists + sid := utils.GetSessionID(c) + if sid == "" { + sid = generateSessionID() + response.SendSessionCookie(c, sid) + } + + // If no configuration found, return error + if config == nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "No signin configuration found for the requested locale", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Return the public configuration + response.RespondWithSuccess(c, response.StatusOK, config) +} + +// login is the handler for login (password login, mapped from /signin) +func login(c *gin.Context) { + // This is a placeholder - the original signin function was empty + // You may need to implement the actual login logic here +} + +// LoginThirdParty is the handler for third party login +func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip string) (*LoginResponse, error) { + + // Get provider + provider, err := GetProvider(providerID) + if err != nil { + return nil, err + } + + // Check if user exists + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return nil, err + } + + // Auto register user if not exists + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var userID string + + // Auto register user if not exists + if provider.Register != nil && provider.Register.Auto { + userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub) + if err != nil && err.Error() == user.ErrOAuthAccountNotFound { + + userData := map[string]interface{}{ + "name": userinfo.Name, + "given_name": userinfo.GivenName, + "family_name": userinfo.FamilyName, + "picture": userinfo.Picture, + "role_id": provider.Register.Role, + "status": "active", + } + + // Auto register user + userID, err = userProvider.CreateUser(ctx, userData) + if err != nil { + return nil, err + } + + // Create OAuth account + userData = userinfo.Map() + userData["provider"] = providerID + _, err = userProvider.CreateOAuthAccount(ctx, userID, userData) + if err != nil { + return nil, err + } + } + } + + // Get User ID from OAuth account + userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub) + if err != nil { + return nil, err + } + + return LoginByUserID(userID, ip) +} + +// LoginByUserID is the handler for login +func LoginByUserID(userid string, ip string) (*LoginResponse, error) { + + // Get User + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return nil, err + } + + // Get User + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + user, err := userProvider.GetUserWithScopes(ctx, userid) + if err != nil { + return nil, err + } + + // Update Last Login + err = userProvider.UpdateUserLastLogin(ctx, userid, ip) + if err != nil { + log.Warn("Failed to update last login: %s", err.Error()) + } + + yaoClientConfig := GetYaoClientConfig() + var scopes []string = yaoClientConfig.Scopes + if v, ok := user["scopes"].([]string); ok { + scopes = v + } + + subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, userid) + if err != nil { + log.Warn("Failed to store user fingerprint: %s", err.Error()) + } + oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user) + oidcUserInfo.Sub = subject + + // OIDC Token + oidcToken, err := oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo) + if err != nil { + return nil, err + } + + // Access Token + accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn) + if err != nil { + return nil, err + } + + // Refresh Token + refreshToken, err := oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn) + if err != nil { + return nil, err + } + + return &LoginResponse{ + AccessToken: accessToken, + IDToken: oidcToken, + RefreshToken: refreshToken, + ExpiresIn: yaoClientConfig.ExpiresIn, + RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn, + TokenType: "Bearer", + Scope: strings.Join(scopes, " "), + }, nil +} + +// generateSessionID generates a session ID +func generateSessionID() string { + return session.ID() +} diff --git a/openapi/user/oauth.go b/openapi/user/oauth.go new file mode 100644 index 00000000..4114829f --- /dev/null +++ b/openapi/user/oauth.go @@ -0,0 +1,644 @@ +package user + +import ( + "fmt" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/yaoapp/gou/session" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/openapi/utils" +) + +// authbackPrepare receives the post data and forwards to the authback handler +func authbackPrepare(c *gin.Context) { + code := c.PostForm("code") + state := c.PostForm("state") + user := c.PostForm("user") // form_post may include user info + providerID := c.Param("provider") + redirectURI, err := getRedirectURI(providerID, state) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to get redirect URI", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Cache user info if provided (form_post mode) + if user != "" { + saveUserInfo(providerID, state, user) + } + + params := url.Values{} + params.Add("code", code) + params.Add("state", state) + c.Redirect(http.StatusFound, redirectURI+"?"+params.Encode()) +} + +// authback is the handler for OAuth callback +func authback(c *gin.Context) { + sid := utils.GetSessionID(c) + var params OAuthAuthbackRequest + providerID := c.Param("provider") + + // Check if provider exists first + provider, err := GetProvider(providerID) + if err != nil || provider == nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("OAuth provider '%s' not found", providerID), + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + if err := c.ShouldBind(¶ms); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid request", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + if params.State == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "State is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + if err := validateState(providerID, sid, params.State); err != nil { + log.With(log.F{"sid": sid, "state": params.State}).Error("Invalid state") + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid state", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get redirect URI + redirectURI, err := getRedirectURI(providerID, params.State) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to get redirect URI", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Get provider + provider, err = GetProvider(providerID) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Failed to get provider: %v", err), + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // if response mode is form_post + if provider.ResponseMode == "form_post" { + // Replace the redirectURI to + pathname := strings.TrimSuffix(c.Request.URL.Path, "/callback") + "/authorize/prepare" + newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c) + if err != nil { + log.Error("Failed to reconstruct redirectURI: %v", err) + response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid redirect URI format", + }) + return + } + redirectURI = newRedirectURI + } + + // Get AccessToken + tokenResponse, err := provider.AccessToken(params.Code, redirectURI) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Failed to get user info: %v", err), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Read cached user info before cleaning up (for form_post mode) + cachedUserInfo, _ := getUserInfo(providerID, params.State) + + // Remove the state from the session and cache (also cleans up user cache automatically) + err = removeState(providerID, sid) + if err != nil { + log.With(log.F{"sid": sid, "providerID": providerID}).Error("Failed to remove state") + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to remove state", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Get UserInfo - use different method based on user_info_source + var userInfo *OAuthUserInfoResponse + if provider.UserInfoSource == UserInfoSourceIDToken { + // For OAuth providers that use id_token, pass cached user info for merging + userInfo, err = provider.GetUserInfoFromTokenResponse(tokenResponse, cachedUserInfo) + } else { + // For standard OAuth providers that use userinfo endpoint + userInfo, err = provider.GetUserInfo(tokenResponse.AccessToken, tokenResponse.TokenType) + } + + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Failed to get user info: %v", err), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // LoginThirdParty(providerID, userInfo) + loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c)) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to login: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Authorize Cookie + accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken) + refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken) + + // Send Cookie + expires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second) + refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second) + response.SendAccessTokenCookieWithExpiry(c, accessToken, expires) + response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires) + + // Send IDToken to the client + response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{"id_token": loginResponse.IDToken}) +} + +// getOAuthAuthorizationURL generates OAuth authorization URL for a provider +func getOAuthAuthorizationURL(c *gin.Context) { + providerID := c.Param("provider") + if providerID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Provider ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get optional parameters + redirectURI := c.Query("redirect_uri") + state := c.Query("state") + + provider, err := GetProvider(providerID) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to get provider", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + if provider == nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("OAuth provider '%s' not found", providerID), + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Validate required provider configuration + if provider.ClientID == "" || provider.Endpoints == nil || provider.Endpoints.Authorization == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Provider configuration is incomplete", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check if state is provided by user and validate format + var warnings []string + + // Generate state if not provided + if state == "" { + var err error + state, err = generateRandomState() + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to generate OAuth state", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + } else { + // User provided state - check if it's in UUID format + if !isValidUUID(state) { + warnings = append(warnings, "State parameter is not in UUID format. For better uniqueness and security, consider using UUID format.") + } + } + + // Set default redirect URI if not provided + if redirectURI == "" { + redirectURI = fmt.Sprintf("%s://%s/auth/callback", getScheme(c), c.Request.Host) + } + + // Build authorization URL + params := url.Values{} + params.Add("client_id", provider.ClientID) + params.Add("response_type", "code") + params.Add("redirect_uri", redirectURI) + params.Add("state", state) + + // Add scopes + if len(provider.Scopes) > 0 { + params.Add("scope", strings.Join(provider.Scopes, " ")) + } + + // Add response_mode if specified (required for Apple with name/email scopes) + if provider.ResponseMode != "" { + params.Add("response_mode", provider.ResponseMode) + } + + // Set session id if not exists + sid := utils.GetSessionID(c) + if sid == "" { + sid = generateSessionID() + response.SendSessionCookie(c, sid) + } + + // Save the state to the session for 20 minutes + err = saveState(providerID, sid, state) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to save OAuth state", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // if response mode is form_post + if provider.ResponseMode == "form_post" { + // Replace the redirectURI to + pathname := c.Request.URL.Path + "/prepare" + newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c) + if err != nil { + log.Error("Failed to reconstruct redirectURI: %v", err) + response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid redirect URI format", + }) + return + } + + params.Set("redirect_uri", newRedirectURI) + } + + // Save the redirect URI to the cache + err = saveRedirectURI(providerID, state, redirectURI) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to save OAuth redirect URI", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Build the authorization URL + authorizationURL := fmt.Sprintf("%s?%s", provider.Endpoints.Authorization, params.Encode()) + + // Return the authorization URL and state + response.RespondWithSuccess(c, response.StatusOK, &OAuthAuthorizationURLResponse{ + AuthorizationURL: authorizationURL, + State: state, + Warnings: warnings, + }) +} + +// Helper functions for OAuth state management + +// generateRandomState generates a UUID-based state parameter for better uniqueness +func generateRandomState() (string, error) { + u := uuid.New() + return u.String(), nil +} + +// isValidUUID checks if a string is a valid UUID format +func isValidUUID(s string) bool { + // UUID v4 format: 8-4-4-4-12 hexadecimal characters + uuidRegex := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + return uuidRegex.MatchString(strings.ToLower(s)) +} + +// getScheme returns the request scheme (http or https) +func getScheme(c *gin.Context) string { + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + return "https" + } + return "http" +} + +// reconstructRedirectURI reconstructs redirectURI with new path while preserving the original host +func reconstructRedirectURI(originalRedirectURI, newPath string, c *gin.Context) (string, error) { + // Parse the original redirectURI to extract host + parsedURL, err := url.Parse(originalRedirectURI) + if err != nil { + return "", fmt.Errorf("failed to parse redirectURI: %v", err) + } + + // Reconstruct with the original host and new path + newRedirectURI := fmt.Sprintf("%s://%s%s", getScheme(c), parsedURL.Host, newPath) + return newRedirectURI, nil +} + +// Cache management functions + +// userInfoKey returns the key for the user info +func userInfoKey(providerID, state string) string { + return fmt.Sprintf("signin:user_info:%s:%s", providerID, state) +} + +// stateKey returns the key for the state +func stateKey(providerID string) string { + return fmt.Sprintf("signin:state:%s", providerID) +} + +// redirectURIKey returns the key for the redirect URI +func redirectURIKey(providerID, state string) string { + return fmt.Sprintf("signin:redirect_uri:%s:%s", providerID, state) +} + +// saveState saves the state to the session +func saveState(providerID, sid, state string) error { + return session.Global().ID(sid).SetWithEx(stateKey(providerID), state, 20*time.Minute) +} + +// saveRedirectURI saves the redirect URI to the session +func saveRedirectURI(providerID, state, redirectURI string) error { + key := redirectURIKey(providerID, state) + store := oauth.OAuth.GetCache() + return store.Set(key, redirectURI, 20*time.Minute) +} + +// getRedirectURI gets the redirect URI from the session +func getRedirectURI(providerID, state string) (string, error) { + key := redirectURIKey(providerID, state) + store := oauth.OAuth.GetCache() + value, ok := store.Get(key) + if !ok || value == nil { + return "", fmt.Errorf("redirect URI not found") + } + return value.(string), nil +} + +func removeRedirectURI(providerID, state string) error { + key := redirectURIKey(providerID, state) + store := oauth.OAuth.GetCache() + return store.Del(key) +} + +// saveUserInfo saves the user info to cache (for form_post mode) +func saveUserInfo(providerID, state, userInfo string) error { + key := userInfoKey(providerID, state) + store := oauth.OAuth.GetCache() + return store.Set(key, userInfo, 20*time.Minute) +} + +// getUserInfo gets the user info from cache +func getUserInfo(providerID, state string) (string, error) { + key := userInfoKey(providerID, state) + store := oauth.OAuth.GetCache() + value, ok := store.Get(key) + if !ok || value == nil { + return "", fmt.Errorf("user info not found") + } + return value.(string), nil +} + +// removeUserInfo removes the user info from cache +func removeUserInfo(providerID, state string) error { + key := userInfoKey(providerID, state) + store := oauth.OAuth.GetCache() + return store.Del(key) +} + +// removeState removes the state from the session +func removeState(providerID, sid string) error { + // Get the state from the session + state, err := session.Global().ID(sid).Get(stateKey(providerID)) + if err != nil { + return err + } + + // Safely convert state to string + stateStr, ok := state.(string) + if !ok { + return fmt.Errorf("invalid state type: expected string, got %T", state) + } + + // Remove all related cached data + removeRedirectURI(providerID, stateStr) + removeUserInfo(providerID, stateStr) + + return session.Global().ID(sid).Del(stateKey(providerID)) +} + +// validateState validates the state from the session +func validateState(providerID, sid, state string) error { + value, err := session.Global().ID(sid).Get(stateKey(providerID)) + if err != nil { + return err + } + + // Safely convert value to string + stateStr, ok := value.(string) + if !ok { + return fmt.Errorf("invalid state type: expected string, got %T", value) + } + + if stateStr != state { + return fmt.Errorf("invalid state") + } + + return nil +} + +// getUserRealIP is the function to get the real IP address of the user +func userIPAddress(c *gin.Context) string { + // Define HTTP headers to check, ordered by priority + headers := []string{ + "X-Real-IP", // Nginx proxy_set_header X-Real-IP + "X-Forwarded-For", // Standard proxy header + "X-Client-IP", // Apache mod_remoteip, Squid + "X-Forwarded", // Legacy proxy standard + "X-Cluster-Client-IP", // Cluster environment + "Forwarded-For", // Pre-RFC 7239 standard + "Forwarded", // RFC 7239 standard + "CF-Connecting-IP", // Cloudflare + "True-Client-IP", // Akamai, CloudFlare Enterprise + "X-Original-Forwarded-For", // Original forwarded + } + + // Check each header one by one + for _, header := range headers { + value := c.GetHeader(header) + if value == "" { + continue + } + + // Handle cases that may contain multiple IPs (e.g., X-Forwarded-For: client, proxy1, proxy2) + ips := parseIPList(value) + for _, ip := range ips { + if isValidPublicIP(ip) { + return ip + } + } + } + + // If none found, use the remote address of the connection + remoteAddr := c.Request.RemoteAddr + if ip := extractIPFromAddr(remoteAddr); ip != "" && isValidPublicIP(ip) { + return ip + } + + // Final fallback, return RemoteAddr (may include port) + return extractIPFromAddr(remoteAddr) +} + +// parseIPList parses IP list string, handles comma-separated multiple IPs +func parseIPList(value string) []string { + var ips []string + + // Handle RFC 7239 Forwarded header format: for=192.0.2.60;proto=http;by=203.0.113.43 + if strings.Contains(value, "for=") { + parts := strings.Split(value, ";") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "for=") { + ip := strings.TrimPrefix(part, "for=") + // Remove possible quotes and brackets + ip = strings.Trim(ip, "\"[]") + if ip != "" { + ips = append(ips, ip) + } + } + } + } else { + // Handle comma-separated IP list + parts := strings.Split(value, ",") + for _, part := range parts { + ip := strings.TrimSpace(part) + if ip != "" { + ips = append(ips, ip) + } + } + } + + return ips +} + +// extractIPFromAddr extracts IP from address (which may include port) +func extractIPFromAddr(addr string) string { + if addr == "" { + return "" + } + + // Handle IPv6 format [::1]:8080 + if strings.HasPrefix(addr, "[") { + if idx := strings.Index(addr, "]:"); idx != -1 { + return addr[1:idx] + } + return strings.Trim(addr, "[]") + } + + // Handle IPv4 format 127.0.0.1:8080 + if idx := strings.LastIndex(addr, ":"); idx != -1 { + return addr[:idx] + } + + return addr +} + +// isValidPublicIP checks if the IP is a valid public IP +func isValidPublicIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + // Filter out private IPs, local IPs, etc. + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return false + } + + // Check if it's a private IP range + if ip.To4() != nil { + // IPv4 private address ranges + return !isPrivateIPv4(ip) + } + // IPv6 private address ranges + return !isPrivateIPv6(ip) +} + +// isPrivateIPv4 checks if it's an IPv4 private address +func isPrivateIPv4(ip net.IP) bool { + // 10.0.0.0/8 + if ip[12] == 10 { + return true + } + // 172.16.0.0/12 + if ip[12] == 172 && ip[13] >= 16 && ip[13] <= 31 { + return true + } + // 192.168.0.0/16 + if ip[12] == 192 && ip[13] == 168 { + return true + } + // 169.254.0.0/16 (Link-Local) + if ip[12] == 169 && ip[13] == 254 { + return true + } + return false +} + +// isPrivateIPv6 checks if it's an IPv6 private address +func isPrivateIPv6(ip net.IP) bool { + // fc00::/7 (Unique Local) + if ip[0] >= 0xfc && ip[0] <= 0xfd { + return true + } + // fe80::/10 (Link-Local) + if ip[0] == 0xfe && (ip[1]&0xc0) == 0x80 { + return true + } + return false +} diff --git a/openapi/user/provider.go b/openapi/user/provider.go new file mode 100644 index 00000000..cef82ae9 --- /dev/null +++ b/openapi/user/provider.go @@ -0,0 +1,1159 @@ +package user + +import ( + "crypto/ecdsa" + "crypto/hmac" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/yaoapp/gou/application" + "github.com/yaoapp/gou/http" + "github.com/yaoapp/kun/log" + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" +) + +// convertToString converts various types to string, avoiding scientific notation for numbers +func (p *Provider) convertToString(value interface{}) string { + // Handle nil values + if value == nil { + return "" + } + + switch v := value.(type) { + case string: + return v + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case float64: + // Check if it's actually an integer value + if v == float64(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + case float32: + // Check if it's actually an integer value + if v == float32(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(float64(v), 'f', -1, 32) + case bool: + return strconv.FormatBool(v) + case []interface{}: + // Handle empty arrays + if len(v) == 0 { + return "" + } + return fmt.Sprintf("%v", v) + default: + return fmt.Sprintf("%v", v) + } +} + +// getPresetMappings returns built-in field mappings for different providers +func getPresetMappings() map[string]map[string]string { + return map[string]map[string]string{ + MappingGoogle: { + "sub": "sub", + "id": "sub", // fallback + "name": "name", + "given_name": "given_name", + "family_name": "family_name", + "email": "email", + "email_verified": "email_verified", + "picture": "picture", + "locale": "locale", + }, + MappingGitHub: { + "id": "sub", + "login": "preferred_username", + "name": "name", + "email": "email", + "avatar_url": "picture", + "blog": "website", + "html_url": "profile", + "location": "address.formatted", + "updated_at": "updated_at", + }, + MappingMicrosoft: { + "id": "sub", + "displayName": "name", + "givenName": "given_name", + "surname": "family_name", + "mail": "email", + "userPrincipalName": "preferred_username", + "mobilePhone": "phone_number", // Priority 1: Personal mobile phone + "businessPhones[0]": "phone_number", // Priority 2: First business phone using array access + "preferredLanguage": "locale", + "officeLocation": "address.locality", + // jobTitle will remain in raw data as OIDC has no direct equivalent + }, + MappingApple: { + "sub": "sub", + "email": "email", + "email_verified": "email_verified", + "preferred_username": "preferred_username", + // form_post provides name information in nested structure - using generic nested access + "name.firstName": "given_name", + "name.lastName": "family_name", + "name": "name", // Full name object will be handled by mapping logic + }, + MappingWeChat: { + "openid": "sub", + "nickname": "nickname", + "headimgurl": "picture", + "sex": "gender", + "country": "address.country", + "province": "address.region", + "city": "address.locality", + }, + MappingGeneric: { + "sub": "sub", + "id": "sub", + "user_id": "sub", + "openid": "sub", + "name": "name", + "display_name": "name", + "displayName": "name", + "full_name": "name", + "fullName": "name", + "given_name": "given_name", + "first_name": "given_name", + "firstName": "given_name", + "family_name": "family_name", + "last_name": "family_name", + "lastName": "family_name", + "surname": "family_name", + "middle_name": "middle_name", + "middleName": "middle_name", + "nickname": "nickname", + "nick": "nickname", + "preferred_username": "preferred_username", + "username": "preferred_username", + "login": "preferred_username", + "screen_name": "preferred_username", + "user_name": "preferred_username", + "profile": "profile", + "profile_url": "profile", + "picture": "picture", + "avatar": "picture", + "avatar_url": "picture", + "profile_image_url": "picture", + "headimgurl": "picture", + "website": "website", + "blog": "website", + "url": "website", + "email": "email", + "mail": "email", + "email_address": "email", + "email_verified": "email_verified", + "verified_email": "email_verified", + "gender": "gender", + "sex": "gender", + "birthdate": "birthdate", + "birthday": "birthdate", + "birth_date": "birthdate", + "zoneinfo": "zoneinfo", + "timezone": "zoneinfo", + "time_zone": "zoneinfo", + "locale": "locale", + "language": "locale", + "lang": "locale", + "phone_number": "phone_number", + "phone": "phone_number", + "mobile": "phone_number", + "mobile_phone": "phone_number", + "mobilePhone": "phone_number", + "updated_at": "updated_at", + "last_modified": "updated_at", + "modified_at": "updated_at", + }, + } +} + +// getFieldMapping resolves the mapping configuration and returns the actual field mapping +func (p *Provider) getFieldMapping() map[string]string { + if p.Mapping == nil { + // Case 3: nil/empty - use generic mapping + return getPresetMappings()[MappingGeneric] + } + + switch mapping := p.Mapping.(type) { + case string: + // Case 1: string (preset enum) + if presetMapping, exists := getPresetMappings()[mapping]; exists { + return presetMapping + } + // If preset not found, fallback to generic + log.Warn("Unknown preset mapping '%s', falling back to generic mapping", mapping) + return getPresetMappings()[MappingGeneric] + + case map[string]interface{}: + // Convert map[string]interface{} to map[string]string + result := make(map[string]string) + for k, v := range mapping { + if strVal, ok := v.(string); ok { + result[k] = strVal + } + } + return result + + case map[string]string: + // Case 2: map[string]string (custom mapping) + return mapping + + default: + // Invalid type, fallback to generic + log.Warn("Invalid mapping type %T, falling back to generic mapping", mapping) + return getPresetMappings()[MappingGeneric] + } +} + +// GetClientSecret gets the client secret for the provider +func (p *Provider) GetClientSecret() (string, error) { + if p.ClientSecret != "" { + return p.ClientSecret, nil + } + + if p.ClientSecretGenerator == nil { + return "", fmt.Errorf("client secret generator not found, set client_secret or client_secret_generator at least one") + } + + // Generate the client secret using the configured generator + return p.GenerateClientSecret() +} + +// GetUserInfo gets the user information from the provider +func (p *Provider) GetUserInfo(accessToken string, tokenType string) (*oauthtypes.OIDCUserInfo, error) { + if accessToken == "" { + return nil, fmt.Errorf("access_token is required") + } + + // Set default token type if not provided + if tokenType == "" { + tokenType = "Bearer" + } + + // Determine user info source (default to "endpoint") + userInfoSource := p.UserInfoSource + if userInfoSource == "" { + userInfoSource = UserInfoSourceEndpoint + } + + // Handle different user info sources + switch userInfoSource { + case UserInfoSourceEndpoint: + return p.getUserInfoFromEndpoint(accessToken, tokenType) + case UserInfoSourceIDToken: + // For id_token source, we need a different approach since we need the token response + return nil, fmt.Errorf("id_token source requires GetUserInfoFromTokenResponse method instead") + case UserInfoSourceAccessToken: + return p.getUserInfoFromAccessToken(accessToken) + default: + return nil, fmt.Errorf("unsupported user_info_source: %s", userInfoSource) + } +} + +// GetUserInfoFromTokenResponse gets user info from complete token response (for Apple OAuth with id_token) +func (p *Provider) GetUserInfoFromTokenResponse(tokenResponse *OAuthTokenResponse, mergeUserInfo ...string) (*oauthtypes.OIDCUserInfo, error) { + if tokenResponse == nil { + return nil, fmt.Errorf("token response is required") + } + + // Determine user info source (default to "endpoint") + userInfoSource := p.UserInfoSource + if userInfoSource == "" { + userInfoSource = UserInfoSourceEndpoint + } + + // Get user info from different sources + var userInfo *oauthtypes.OIDCUserInfo + var err error + + switch userInfoSource { + case UserInfoSourceEndpoint: + userInfo, err = p.getUserInfoFromEndpoint(tokenResponse.AccessToken, tokenResponse.TokenType) + case UserInfoSourceIDToken: + if tokenResponse.IDToken == "" { + return nil, fmt.Errorf("id_token not found in token response") + } + // Get raw claims from ID token + rawClaims, err := p.verifyIDTokenAndGetClaims(tokenResponse.IDToken) + if err != nil { + return nil, fmt.Errorf("failed to verify ID token: %w", err) + } + + // Merge cached user info into raw claims before mapping + if len(mergeUserInfo) > 0 && mergeUserInfo[0] != "" { + p.mergeFormPostDataIntoClaims(rawClaims, mergeUserInfo[0]) + } + + // Map the merged claims to our standard user info structure + userInfo = p.mapUserInfoResponse(rawClaims) + case UserInfoSourceAccessToken: + userInfo, err = p.getUserInfoFromAccessToken(tokenResponse.AccessToken) + default: + return nil, fmt.Errorf("unsupported user_info_source: %s", userInfoSource) + } + + if err != nil { + return nil, err + } + + return userInfo, nil +} + +// mergeFormPostDataIntoClaims merges user info from form_post data into raw claims before mapping +func (p *Provider) mergeFormPostDataIntoClaims(rawClaims map[string]interface{}, cachedUserInfo string) { + var userData map[string]interface{} + if err := json.Unmarshal([]byte(cachedUserInfo), &userData); err != nil { + log.Warn("Failed to parse cached user info: %v", err) + return + } + + // Merge cached data into raw claims, but preserve existing claims (ID Token data is more reliable) + // The mapping logic will handle all field conversions + for key, value := range userData { + if _, exists := rawClaims[key]; !exists { + rawClaims[key] = value + } + } +} + +// getUserInfoFromEndpoint gets user info from a dedicated endpoint (default behavior) +func (p *Provider) getUserInfoFromEndpoint(accessToken string, tokenType string) (*oauthtypes.OIDCUserInfo, error) { + if p.Endpoints == nil { + return nil, fmt.Errorf("endpoints not found, set endpoints at least one") + } + + if p.Endpoints.UserInfo == "" { + return nil, fmt.Errorf("user_info endpoint not found, set user_info endpoint at least one") + } + + // Create HTTP request with authorization header + req := http.New(p.Endpoints.UserInfo). + SetHeader("Authorization", fmt.Sprintf("%s %s", tokenType, accessToken)). + SetHeader("Accept", "application/json"). + SetHeader("User-Agent", "Yao-OAuth-Client/1.0") + + // Make the GET request + resp := req.Get() + if resp == nil { + return nil, fmt.Errorf("failed to make user info request: no response") + } + + // Check for HTTP errors + if resp.Code != 200 { + if resp.Data != nil { + + // === Parse the response data === + if data, ok := resp.Data.(map[string]interface{}); ok { + // Handle standard OAuth error format + if err, ok := data["error_description"]; ok { + return nil, fmt.Errorf("%v", err) + } + if err, ok := data["error"]; ok { + // Handle Microsoft Graph nested error format + if errorObj, isMap := err.(map[string]interface{}); isMap { + if code, hasCode := errorObj["code"]; hasCode { + if message, hasMessage := errorObj["message"]; hasMessage && message != "" { + return nil, fmt.Errorf("Microsoft Graph error %v: %v", code, message) + } + return nil, fmt.Errorf("Microsoft Graph error: %v", code) + } + } + return nil, fmt.Errorf("%v", err) + } + } + } + + if resp.Message != "" { + return nil, fmt.Errorf("user info request failed with status %d: %s", resp.Code, resp.Message) + } + return nil, fmt.Errorf("user info request failed with status %d", resp.Code) + } + + // Parse the response data + var rawData map[string]interface{} + switch data := resp.Data.(type) { + case map[string]interface{}: + rawData = data + case []byte: + if err := json.Unmarshal(data, &rawData); err != nil { + return nil, fmt.Errorf("failed to parse user info response from bytes: %w", err) + } + case string: + if err := json.Unmarshal([]byte(data), &rawData); err != nil { + return nil, fmt.Errorf("failed to parse user info response from string: %w", err) + } + default: + return nil, fmt.Errorf("unexpected response data type: %T", data) + } + + // Map the raw response to our standard structure + userInfo := p.mapUserInfoResponse(rawData) + + return userInfo, nil +} + +// verifyIDTokenAndGetClaims verifies ID token signature and returns raw claims for user info mapping +func (p *Provider) verifyIDTokenAndGetClaims(idToken string) (map[string]interface{}, error) { + // Parse token to get header for key ID + token, err := jwt.Parse(idToken, func(token *jwt.Token) (interface{}, error) { + // Verify signing method + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // Get key ID from token header + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing key ID in token header") + } + + // Get public key from JWKS endpoint for verification + publicKey, err := p.getJWKSPublicKey(kid) + if err != nil { + return nil, fmt.Errorf("failed to get JWKS public key: %w", err) + } + + return publicKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse/verify JWT: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid JWT token") + } + + // Extract claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("failed to extract JWT claims") + } + + // Basic validation + if aud, ok := claims["aud"].(string); ok && aud != p.ClientID { + return nil, fmt.Errorf("invalid audience: %s", aud) + } + if exp, ok := claims["exp"].(float64); ok && time.Now().Unix() > int64(exp) { + return nil, fmt.Errorf("token expired") + } + + // Convert jwt.MapClaims to map[string]interface{} + rawClaims := make(map[string]interface{}) + for key, value := range claims { + rawClaims[key] = value + } + + return rawClaims, nil +} + +// getJWKSPublicKey fetches public key from provider's JWKS endpoint +func (p *Provider) getJWKSPublicKey(keyID string) (interface{}, error) { + // Check if JWKS endpoint is configured + if p.Endpoints == nil || p.Endpoints.JWKS == "" { + return nil, fmt.Errorf("JWKS endpoint not configured") + } + + jwksURL := p.Endpoints.JWKS + + // Make HTTP request to get JWKS + req := http.New(jwksURL). + SetHeader("Accept", "application/json"). + SetHeader("User-Agent", "Yao-OAuth-Client/1.0") + + resp := req.Get() + if resp == nil { + return nil, fmt.Errorf("failed to fetch JWKS from %s: no response", jwksURL) + } + + if resp.Code != 200 { + return nil, fmt.Errorf("failed to fetch JWKS from %s: status %d", jwksURL, resp.Code) + } + + // Parse JWKS response + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + + // Handle different response data types + switch data := resp.Data.(type) { + case map[string]interface{}: + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal JWKS response: %w", err) + } + if err := json.Unmarshal(jsonBytes, &jwks); err != nil { + return nil, fmt.Errorf("failed to parse JWKS response: %w", err) + } + case []byte: + if err := json.Unmarshal(data, &jwks); err != nil { + return nil, fmt.Errorf("failed to parse JWKS response: %w", err) + } + case string: + if err := json.Unmarshal([]byte(data), &jwks); err != nil { + return nil, fmt.Errorf("failed to parse JWKS response: %w", err) + } + default: + return nil, fmt.Errorf("unexpected JWKS response data type: %T", data) + } + + // Find the key with matching kid + for _, key := range jwks.Keys { + if key.Kid == keyID && key.Kty == "RSA" { + // Decode RSA public key components + nBytes, err := base64.RawURLEncoding.DecodeString(key.N) + if err != nil { + return nil, fmt.Errorf("failed to decode RSA modulus: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + return nil, fmt.Errorf("failed to decode RSA exponent: %w", err) + } + + // Convert exponent bytes to int + var eInt int64 + for _, b := range eBytes { + eInt = eInt<<8 + int64(b) + } + + // Create RSA public key + rsaKey := &rsa.PublicKey{ + N: big.NewInt(0).SetBytes(nBytes), + E: int(eInt), + } + + return rsaKey, nil + } + } + + return nil, fmt.Errorf("public key not found for key ID: %s", keyID) +} + +// getUserInfoFromAccessToken gets user info from access token response +func (p *Provider) getUserInfoFromAccessToken(accessToken string) (*oauthtypes.OIDCUserInfo, error) { + // This is a placeholder implementation + // In a real implementation, this would parse structured data from the access token response + // or decode a JWT access token if the provider uses JWT access tokens + return &oauthtypes.OIDCUserInfo{ + Sub: "access_token_user", // Placeholder + Raw: map[string]interface{}{ + "note": "User info extracted from access token", + "access_token": accessToken, + }, + }, nil +} + +// mapUserInfoResponse maps raw OAuth user info response to our standard structure +func (p *Provider) mapUserInfoResponse(rawData map[string]interface{}) *oauthtypes.OIDCUserInfo { + userInfo := &oauthtypes.OIDCUserInfo{ + Raw: rawData, // Keep raw data for debugging/custom processing + } + + // Get the appropriate field mapping (preset, custom, or generic) + fieldMapping := p.getFieldMapping() + + // Apply field mappings with support for nested field access + for sourceField, targetField := range fieldMapping { + var value interface{} + var exists bool + + // Check if it's a nested field (contains dots or array notation) + if strings.Contains(sourceField, ".") || strings.Contains(sourceField, "[") { + value = p.getNestedValue(rawData, sourceField) + exists = (value != nil) + } else { + // Simple field access + value, exists = rawData[sourceField] + } + + if exists { + p.setUserInfoField(userInfo, targetField, value) + } + } + + // Post-processing: set fallback values + p.applyFallbackValues(userInfo, rawData) + + return userInfo +} + +// getNestedValue retrieves a value from nested object/array using dot notation and array indexing +// Supports: "name.firstName", "address.country", "businessPhones[0]", "roles[1].name" +func (p *Provider) getNestedValue(data map[string]interface{}, path string) interface{} { + // Split path by dots + parts := strings.Split(path, ".") + current := interface{}(data) + + for _, part := range parts { + // Handle array indexing: fieldName[index] + if strings.Contains(part, "[") && strings.HasSuffix(part, "]") { + // Extract field name and index + openBracket := strings.Index(part, "[") + fieldName := part[:openBracket] + indexStr := part[openBracket+1 : len(part)-1] + + // Get the field first + if currentMap, ok := current.(map[string]interface{}); ok { + if field, exists := currentMap[fieldName]; exists { + current = field + } else { + return nil + } + } else { + return nil + } + + // Handle array access + if currentArray, ok := current.([]interface{}); ok { + if index, err := strconv.Atoi(indexStr); err == nil && index >= 0 && index < len(currentArray) { + current = currentArray[index] + } else { + return nil + } + } else { + return nil + } + } else { + // Handle simple field access + if currentMap, ok := current.(map[string]interface{}); ok { + if field, exists := currentMap[part]; exists { + current = field + } else { + return nil + } + } else { + return nil + } + } + } + + return current +} + +// setUserInfoField sets a field in the user info structure +func (p *Provider) setUserInfoField(userInfo *oauthtypes.OIDCUserInfo, fieldName string, value interface{}) { + // Handle nested address fields + if strings.HasPrefix(fieldName, "address.") { + stringValue := p.convertToString(value) + // Skip empty values + if stringValue == "" { + return + } + + if userInfo.Address == nil { + userInfo.Address = &oauthtypes.OIDCAddress{} + } + + addressField := strings.TrimPrefix(fieldName, "address.") + + switch addressField { + case "formatted": + userInfo.Address.Formatted = stringValue + case "street_address": + userInfo.Address.StreetAddress = stringValue + case "locality": + userInfo.Address.Locality = stringValue + case "region": + userInfo.Address.Region = stringValue + case "postal_code": + userInfo.Address.PostalCode = stringValue + case "country": + userInfo.Address.Country = stringValue + } + return + } + + stringValue := p.convertToString(value) + + // Skip empty values for most fields + if stringValue == "" && fieldName != "phone_number" { + return + } + + switch fieldName { + // OIDC Standard Claims + case "sub": + userInfo.Sub = stringValue + case "name": + // Handle name as object (e.g., Apple form_post: {"firstName": "John", "lastName": "Doe"}) + if nameObj, ok := value.(map[string]interface{}); ok { + var nameParts []string + if firstName, exists := nameObj["firstName"]; exists { + if firstNameStr := p.convertToString(firstName); firstNameStr != "" { + nameParts = append(nameParts, firstNameStr) + if userInfo.GivenName == "" { + userInfo.GivenName = firstNameStr + } + } + } + if lastName, exists := nameObj["lastName"]; exists { + if lastNameStr := p.convertToString(lastName); lastNameStr != "" { + nameParts = append(nameParts, lastNameStr) + if userInfo.FamilyName == "" { + userInfo.FamilyName = lastNameStr + } + } + } + if len(nameParts) > 0 { + userInfo.Name = strings.Join(nameParts, " ") + } + } else { + // Handle name as string + userInfo.Name = stringValue + } + case "given_name": + userInfo.GivenName = stringValue + case "family_name": + userInfo.FamilyName = stringValue + case "middle_name": + userInfo.MiddleName = stringValue + case "nickname": + userInfo.Nickname = stringValue + case "preferred_username": + userInfo.PreferredUsername = stringValue + case "profile": + userInfo.Profile = stringValue + case "picture": + userInfo.Picture = stringValue + case "website": + userInfo.Website = stringValue + case "email": + userInfo.Email = stringValue + case "email_verified": + if boolValue, ok := value.(bool); ok { + userInfo.EmailVerified = &boolValue + } + case "gender": + // Handle special gender conversion for WeChat + if floatValue, ok := value.(float64); ok { + switch int(floatValue) { + case 1: + userInfo.Gender = "male" + case 2: + userInfo.Gender = "female" + default: + userInfo.Gender = "unknown" + } + } else { + userInfo.Gender = stringValue + } + case "birthdate": + userInfo.Birthdate = stringValue + case "zoneinfo": + userInfo.Zoneinfo = stringValue + case "locale": + userInfo.Locale = stringValue + case "phone_number": + // Only set if we don't already have a phone number + if userInfo.PhoneNumber != "" { + return + } + + // Handle array type for Microsoft businessPhones + if phoneArray, ok := value.([]interface{}); ok && len(phoneArray) > 0 { + // Take the first non-empty phone number from the array + for _, phone := range phoneArray { + if phoneStr := p.convertToString(phone); phoneStr != "" { + userInfo.PhoneNumber = phoneStr + break + } + } + } else { + // Handle single phone number (mobilePhone) + if stringValue != "" { + userInfo.PhoneNumber = stringValue + } + } + case "phone_number_verified": + if boolValue, ok := value.(bool); ok { + userInfo.PhoneNumberVerified = &boolValue + } + case "updated_at": + if intValue, ok := value.(int64); ok { + userInfo.UpdatedAt = &intValue + } else if stringValue, ok := value.(string); ok { + // Handle ISO 8601 time strings (e.g., from GitHub) + if parsedTime, err := time.Parse(time.RFC3339, stringValue); err == nil { + timestamp := parsedTime.Unix() + userInfo.UpdatedAt = ×tamp + } + } + } +} + +// applyFallbackValues applies fallback values and data cleanup +func (p *Provider) applyFallbackValues(userInfo *oauthtypes.OIDCUserInfo, rawData map[string]interface{}) { + // OIDC Standard: If no name but have given_name/family_name, combine them + if userInfo.Name == "" && (userInfo.GivenName != "" || userInfo.FamilyName != "") { + parts := []string{} + if userInfo.GivenName != "" { + parts = append(parts, userInfo.GivenName) + } + if userInfo.MiddleName != "" { + parts = append(parts, userInfo.MiddleName) + } + if userInfo.FamilyName != "" { + parts = append(parts, userInfo.FamilyName) + } + userInfo.Name = strings.Join(parts, " ") + } + + // Set preferred_username fallbacks + if userInfo.PreferredUsername == "" && userInfo.Email != "" { + if atIndex := strings.Index(userInfo.Email, "@"); atIndex > 0 { + userInfo.PreferredUsername = userInfo.Email[:atIndex] + } + } + + // OIDC requires Sub to be always set + if userInfo.Sub == "" { + log.Error("Subject identifier (sub) not found in OAuth response for provider '%s'", p.ID) + } +} + +// GenerateClientSecret generates client secret based on the configured generator type +func (p *Provider) GenerateClientSecret() (string, error) { + if p.ClientSecretGenerator == nil { + return "", fmt.Errorf("client secret generator not configured") + } + + switch p.ClientSecretGenerator.Type { + case "JWT_ES256", "JWT_APPLE": // Apple JWT is the same as JWT_ES256 + return p.generateJWTES256() + case "BASIC_CONCAT": + return p.generateBasicConcat() + case "HMAC_SHA256": + return p.generateHMACSignature() + default: + return "", fmt.Errorf("unsupported client secret generator type: %s", p.ClientSecretGenerator.Type) + } +} + +// generateJWTES256 generates JWT client secret using ES256 algorithm +func (p *Provider) generateJWTES256() (string, error) { + gen := p.ClientSecretGenerator + + // Validate required fields + if gen.PrivateKey == "" { + return "", fmt.Errorf("private_key is required for JWT ES256 generation") + } + + if gen.Header == nil { + return "", fmt.Errorf("header is required for JWT ES256 generation") + } + + if gen.Payload == nil { + return "", fmt.Errorf("payload is required for JWT ES256 generation") + } + + // Read private key + privateKey, err := p.loadPrivateKey(gen.PrivateKey) + if err != nil { + return "", fmt.Errorf("failed to load private key: %w", err) + } + + // Parse expiration time (already normalized during config loading) + expiresIn := time.Hour * 24 * 90 // Default 90 days + if gen.ExpiresIn != "" { + duration, err := time.ParseDuration(gen.ExpiresIn) + if err != nil { + // This should not happen since it's normalized during config loading + log.Error("Failed to parse normalized expires_in '%s': %v", gen.ExpiresIn, err) + // Use default duration + } else { + expiresIn = duration + } + } + + // Create JWT token + now := time.Now() + token := jwt.New(jwt.SigningMethodES256) + + // Set header claims + for key, value := range gen.Header { + token.Header[key] = value + } + + // Set payload claims + claims := token.Claims.(jwt.MapClaims) + for key, value := range gen.Payload { + claims[key] = value + } + + // Set standard claims + claims["iat"] = now.Unix() + claims["exp"] = now.Add(expiresIn).Unix() + + // Sign the token + tokenString, err := token.SignedString(privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign JWT: %w", err) + } + + return tokenString, nil +} + +// loadPrivateKey loads and parses the ES256 private key +func (p *Provider) loadPrivateKey(keyPath string) (*ecdsa.PrivateKey, error) { + var keyData []byte + var err error + + // Check if keyPath is absolute or relative to openapi/certs + if filepath.IsAbs(keyPath) { + keyData, err = os.ReadFile(keyPath) + } else { + // Try relative to openapi/certs directory + certPath := filepath.Join("openapi", "certs", keyPath) + keyData, err = application.App.Read(certPath) + } + + if err != nil { + log.Error("failed to read private key file: %v", err) + return nil, fmt.Errorf("failed to read private key file: %w", err) + } + + // Parse PEM block + block, _ := pem.Decode(keyData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + // Parse private key + switch block.Type { + case "EC PRIVATE KEY": + return x509.ParseECPrivateKey(block.Bytes) + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an ECDSA private key") + } + return ecKey, nil + default: + return nil, fmt.Errorf("unsupported private key type: %s", block.Type) + } +} + +// generateBasicConcat generates client secret by concatenating client_id and other values +func (p *Provider) generateBasicConcat() (string, error) { + gen := p.ClientSecretGenerator + + // Default pattern: client_id:timestamp + parts := []string{p.ClientID} + + // Add custom parts from payload + if gen.Payload != nil { + for key, value := range gen.Payload { + if key == "separator" { + continue // Skip separator key + } + parts = append(parts, fmt.Sprintf("%v", value)) + } + } else { + // Add timestamp if no custom payload + parts = append(parts, fmt.Sprintf("%d", time.Now().Unix())) + } + + // Get separator from payload, default to ":" + separator := ":" + if gen.Payload != nil { + if sep, ok := gen.Payload["separator"].(string); ok { + separator = sep + } + } + + return strings.Join(parts, separator), nil +} + +// generateHMACSignature generates client secret using HMAC-SHA256 signature +func (p *Provider) generateHMACSignature() (string, error) { + gen := p.ClientSecretGenerator + + // Get the secret key for HMAC + secretKey := "" + if gen.PrivateKey != "" { + secretKey = gen.PrivateKey + } else if gen.Payload != nil { + if key, ok := gen.Payload["secret_key"].(string); ok { + secretKey = key + } + } + + if secretKey == "" { + return "", fmt.Errorf("secret_key is required for HMAC_SHA256 generation") + } + + // Build message to sign + message := p.ClientID + if gen.Payload != nil { + if msg, ok := gen.Payload["message"].(string); ok { + message = msg + } else if msg, ok := gen.Payload["data"].(string); ok { + message = msg + } + } + + // Add timestamp if configured + if gen.Payload != nil { + if addTimestamp, ok := gen.Payload["add_timestamp"].(bool); ok && addTimestamp { + message += fmt.Sprintf(":%d", time.Now().Unix()) + } + } + + // Create HMAC signature + h := hmac.New(sha256.New, []byte(secretKey)) + h.Write([]byte(message)) + signature := h.Sum(nil) + + // Return as hex or base64 based on configuration + encoding := "hex" // default + if gen.Payload != nil { + if enc, ok := gen.Payload["encoding"].(string); ok { + encoding = enc + } + } + + switch encoding { + case "base64": + return base64.StdEncoding.EncodeToString(signature), nil + case "hex": + return hex.EncodeToString(signature), nil + default: + return hex.EncodeToString(signature), nil + } +} + +// AccessToken gets the access token for the provider using OAuth 2.0 authorization code flow +func (p *Provider) AccessToken(code, redirectURI string) (*OAuthTokenResponse, error) { + if code == "" { + return nil, fmt.Errorf("authorization code is required") + } + + // Get the access token endpoint + if p.Endpoints == nil { + return nil, fmt.Errorf("endpoints not found, set endpoints at least one") + } + + if p.Endpoints.Token == "" { + return nil, fmt.Errorf("token endpoint not found, set token endpoint at least one") + } + + // Get client secret (handles both ClientSecret and ClientSecretGenerator cases) + secret, err := p.GetClientSecret() + if err != nil { + return nil, fmt.Errorf("failed to get client secret: %w", err) + } + + // Prepare the request parameters according to OAuth 2.0 spec + params := map[string]string{ + "grant_type": "authorization_code", + "code": code, + "client_id": p.ClientID, + "client_secret": secret, + "redirect_uri": redirectURI, + } + + // Create HTTP request using gou/http package (with DNS optimization) + req := http.New(p.Endpoints.Token). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + SetHeader("Accept", "application/json"). + SetHeader("User-Agent", "Yao-OAuth-Client/1.0") + + // Make the POST request + resp := req.Post(params) + if resp == nil { + return nil, fmt.Errorf("failed to make token request: no response") + } + + // Check for HTTP errors + if resp.Code != 200 { + if resp.Data != nil { + if data, ok := resp.Data.(map[string]interface{}); ok { + if err, ok := data["error_description"]; ok { + return nil, fmt.Errorf("%v", err) + } + if err, ok := data["error"]; ok { + return nil, fmt.Errorf("%v", err) + } + } + } + + if resp.Message != "" { + return nil, fmt.Errorf("token request failed with status %d: %s", resp.Code, resp.Message) + } + + return nil, fmt.Errorf("token request failed with status %d", resp.Code) + } + + // Parse the JSON response + var tokenResponse OAuthTokenResponse + + // Handle the response data - it could be already parsed JSON or raw bytes + switch data := resp.Data.(type) { + case map[string]interface{}: + // Already parsed JSON, convert to our struct + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal response data: %w", err) + } + if err := json.Unmarshal(jsonBytes, &tokenResponse); err != nil { + return nil, fmt.Errorf("failed to parse token response from parsed JSON: %w", err) + } + case []byte: + // Raw bytes, parse as JSON + if err := json.Unmarshal(data, &tokenResponse); err != nil { + return nil, fmt.Errorf("failed to parse token response from bytes: %w", err) + } + case string: + // String response, parse as JSON + if err := json.Unmarshal([]byte(data), &tokenResponse); err != nil { + return nil, fmt.Errorf("failed to parse token response from string: %w", err) + } + default: + return nil, fmt.Errorf("unexpected response data type: %T", data) + } + + // Check for OAuth error response + if tokenResponse.Error != "" { + errorMsg := tokenResponse.Error + if tokenResponse.ErrorDesc != "" { + errorMsg += ": " + tokenResponse.ErrorDesc + } + return nil, fmt.Errorf("OAuth error: %s", errorMsg) + } + + // Validate that we got an access token + if tokenResponse.AccessToken == "" { + return nil, fmt.Errorf("no access token in response") + } + + return &tokenResponse, nil +} diff --git a/openapi/user/types.go b/openapi/user/types.go new file mode 100644 index 00000000..029efd4b --- /dev/null +++ b/openapi/user/types.go @@ -0,0 +1,190 @@ +package user + +import ( + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Config represents the signin page configuration +type Config struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Default bool `json:"default,omitempty"` + SuccessURL string `json:"success_url,omitempty"` + FailureURL string `json:"failure_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + Form *FormConfig `json:"form,omitempty"` + Token *TokenConfig `json:"token,omitempty"` + ThirdParty *ThirdParty `json:"third_party,omitempty"` +} + +// FormConfig represents the form configuration +type FormConfig struct { + Username *UsernameConfig `json:"username,omitempty"` + Password *PasswordConfig `json:"password,omitempty"` + Captcha *CaptchaConfig `json:"captcha,omitempty"` + ForgotPasswordLink bool `json:"forgot_password_link,omitempty"` + RememberMe bool `json:"remember_me,omitempty"` + RegisterLink string `json:"register_link,omitempty"` + TermsOfServiceLink string `json:"terms_of_service_link,omitempty"` + PrivacyPolicyLink string `json:"privacy_policy_link,omitempty"` +} + +// UsernameConfig represents the username field configuration +type UsernameConfig struct { + Placeholder string `json:"placeholder,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +// PasswordConfig represents the password field configuration +type PasswordConfig struct { + Placeholder string `json:"placeholder,omitempty"` +} + +// CaptchaConfig represents the captcha configuration +type CaptchaConfig struct { + Type string `json:"type,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` +} + +// TokenConfig represents the token configuration +type TokenConfig struct { + ExpiresIn string `json:"expires_in,omitempty"` + RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"` +} + +// ThirdParty represents the third party login configuration +type ThirdParty struct { + Providers []*Provider `json:"providers,omitempty"` +} + +// RegisterConfig represents the auto register configuration +type RegisterConfig struct { + Auto bool `json:"auto,omitempty"` + Role string `json:"role,omitempty"` +} + +// YaoClientConfig represents the Yao OpenAPI Client config +type YaoClientConfig struct { + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + Scopes []string `json:"scopes,omitempty"` // Default scopes if not set in the provider config + ExpiresIn int `json:"expires_in,omitempty"` // Default expires in for the access token (optional) in seconds + RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` // Default expires in for the refresh token (optional) in seconds +} + +// Provider represents a third party login provider +type Provider struct { + ID string `json:"id,omitempty"` + Label string `json:"label,omitempty"` + Title string `json:"title,omitempty"` + Logo string `json:"logo,omitempty"` + Color string `json:"color,omitempty"` + TextColor string `json:"text_color,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"` + Scopes []string `json:"scopes,omitempty"` + ResponseMode string `json:"response_mode,omitempty"` + UserInfoSource string `json:"user_info_source,omitempty"` // "endpoint" (default) | "id_token" | "access_token" + Endpoints *Endpoints `json:"endpoints,omitempty"` + Mapping interface{} `json:"mapping,omitempty"` // string (preset) | map[string]string (custom) | nil (generic) + Register *RegisterConfig `json:"register,omitempty"` +} + +// SecretGenerator represents the client secret generator configuration +type SecretGenerator struct { + Type string `json:"type,omitempty"` + ExpiresIn string `json:"expires_in,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + Header map[string]interface{} `json:"header,omitempty"` + Payload map[string]interface{} `json:"payload,omitempty"` +} + +// Endpoints represents the OAuth endpoints +type Endpoints struct { + Authorization string `json:"authorization,omitempty"` + Token string `json:"token,omitempty"` + UserInfo string `json:"user_info,omitempty"` + JWKS string `json:"jwks,omitempty"` // JSON Web Key Set endpoint for token verification +} + +// ==== API Types ==== + +// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL +type OAuthAuthorizationURLResponse struct { + AuthorizationURL string `json:"authorization_url"` + State string `json:"state"` + Warnings []string `json:"warnings,omitempty"` // Optional warnings about state format or other issues +} + +// OAuthCallbackResponse represents the response for OAuth callback +type OAuthCallbackResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +// OAuthAuthbackRequest represents the request for OAuth callback +type OAuthAuthbackRequest struct { + Locale string `json:"locale" form:"locale"` + Code string `json:"code" form:"code"` + State string `json:"state" form:"state"` + Provider string `json:"provider" form:"provider"` + Scope string `json:"scope,omitempty" form:"scope,omitempty"` +} + +// OAuthTokenResponse represents the response from OAuth token endpoint +type OAuthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + IDToken string `json:"id_token,omitempty"` // JWT token containing user info (Apple, etc.) + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// OAuthTokenRequest represents the request to OAuth token endpoint +type OAuthTokenRequest struct { + GrantType string `json:"grant_type" form:"grant_type"` + Code string `json:"code" form:"code"` + ClientID string `json:"client_id" form:"client_id"` + ClientSecret string `json:"client_secret" form:"client_secret"` + RedirectURI string `json:"redirect_uri,omitempty" form:"redirect_uri,omitempty"` +} + +// OAuthUserInfoResponse is an alias for OIDC standard user information type +type OAuthUserInfoResponse = oauthtypes.OIDCUserInfo + +// OIDCAddress is an alias for OIDC standard address claim type +type OIDCAddress = oauthtypes.OIDCAddress + +// LoginResponse represents the response for login +type LoginResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` + TokenType string `json:"token_type,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// Built-in preset mapping types +const ( + MappingGoogle = "google" + MappingGitHub = "github" + MappingMicrosoft = "microsoft" + MappingApple = "apple" + MappingWeChat = "wechat" + MappingGeneric = "generic" +) + +// User info source types +const ( + UserInfoSourceEndpoint = "endpoint" // Default: Get user info from dedicated endpoint + UserInfoSourceIDToken = "id_token" // Extract user info from ID token (JWT) + UserInfoSourceAccessToken = "access_token" // Extract user info from access token response +) diff --git a/openapi/user/user.go b/openapi/user/user.go index ef991607..6dc81335 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -10,9 +10,9 @@ import ( // Attach attaches the signin handlers to the router func Attach(group *gin.RouterGroup, oauth types.OAuth) { - // User Authentication - group.GET("/login", placeholder) // Get login page config (public) - group.POST("/login", placeholder) // User login (public) + // User Authentication (migrated from /signin) + group.GET("/login", getLoginConfig) // Get login page config (public) - migrated from /signin + group.POST("/login", login) // User login (public) - migrated from /signin group.POST("/register", placeholder) // User register (public) group.POST("/logout", oauth.Guard, placeholder) // User logout @@ -229,11 +229,11 @@ func attachThirdParty(group *gin.RouterGroup, oauth types.OAuth) { thirdParty.GET("/providers", oauth.Guard, placeholder) // Get linked OAuth providers thirdParty.DELETE("/:provider", oauth.Guard, placeholder) // Unlink OAuth provider - thirdParty.GET("/providers/available", placeholder) // Get available OAuth providers - thirdParty.GET("/:provider/authorize", placeholder) // Get OAuth authorization URL - thirdParty.POST("/:provider/connect", oauth.Guard, placeholder) // Connect OAuth provider - thirdParty.POST("/:provider/authorize/prepare", placeholder) // Get OAuth authorization URL - thirdParty.POST("/:provider/callback", placeholder) // Handle OAuth callback + thirdParty.GET("/providers/available", placeholder) // Get available OAuth providers + thirdParty.GET("/:provider/authorize", getOAuthAuthorizationURL) // Get OAuth authorization URL - migrated from /signin/oauth/:provider/authorize + thirdParty.POST("/:provider/connect", oauth.Guard, placeholder) // Connect OAuth provider + thirdParty.POST("/:provider/authorize/prepare", authbackPrepare) // OAuth authorization prepare - migrated from /signin/oauth/:provider/authorize/prepare + thirdParty.POST("/:provider/callback", authback) // Handle OAuth callback - migrated from /signin/oauth/:provider/authback }