diff --git a/example.env b/example.env index e645c96e9c..f7e09e7876 100644 --- a/example.env +++ b/example.env @@ -236,3 +236,7 @@ GOTRUE_SMS_TEST_OTP_VALID_UNTIL="" # (e.g. 2023-09-29T08:14:06Z) GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED="false" GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED="false" + +# Steam Provider +GOTRUE_EXTERNAL_STEAM_ENABLED=false +GOTRUE_EXTERNAL_STEAM_REALM="http://localhost:9999" diff --git a/hack/test.env b/hack/test.env index 35e4b61c81..9e2a40cba5 100644 --- a/hack/test.env +++ b/hack/test.env @@ -126,3 +126,5 @@ GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPT=true GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY_ID=abc GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY=pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4 GOTRUE_SECURITY_DB_ENCRYPTION_DECRYPTION_KEYS=abc:pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4 +GOTRUE_EXTERNAL_STEAM_ENABLED=true +GOTRUE_EXTERNAL_STEAM_REALM="http://localhost:9999" diff --git a/internal/api/external_steam_test.go b/internal/api/external_steam_test.go new file mode 100644 index 0000000000..3c1b8d0eee --- /dev/null +++ b/internal/api/external_steam_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt/v5" +) + +func (ts *ExternalTestSuite) TestSignupExternalSteam() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=steam", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + + q := u.Query() + ts.Equal("checkid_setup", q.Get("openid.mode")) + ts.Equal("http://specs.openid.net/auth/2.0", q.Get("openid.ns")) + ts.Equal(ts.Config.External.Steam.Realm, q.Get("openid.realm")) + + claims := ExternalProviderClaims{} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("steam", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} \ No newline at end of file diff --git a/internal/api/provider/steam.go b/internal/api/provider/steam.go new file mode 100644 index 0000000000..a64cdb1810 --- /dev/null +++ b/internal/api/provider/steam.go @@ -0,0 +1,105 @@ +package provider + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/supabase/auth/internal/conf" +) + +const ( + defaultSteamAuthBase = "steamcommunity.com" + steamOpenIDEndpoint = "https://steamcommunity.com/openid/login" +) + +type steamProvider struct { + Realm string + APIPath string +} + +var steamIDRegex = regexp.MustCompile(`^https?:\/\/steamcommunity\.com\/openid\/id\/(\d+)\/?$`) + +// NewSteamProvider creates a Steam account provider. +func NewSteamProvider(ext conf.OAuthProviderConfiguration) (Provider, error) { + if ext.Realm == "" { + return nil, errors.New("No realm specified for Steam provider") + } + + return &steamProvider{ + Realm: ext.Realm, + APIPath: chooseHost(ext.URL, defaultSteamAuthBase), + }, nil +} + +func (p steamProvider) GetAuthorizationURL(state string) string { + params := url.Values{} + params.Add("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select") + params.Add("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select") + params.Add("openid.mode", "checkid_setup") + params.Add("openid.ns", "http://specs.openid.net/auth/2.0") + params.Add("openid.realm", p.Realm) + params.Add("openid.return_to", fmt.Sprintf("%s?state=%s", p.Realm, state)) + + return steamOpenIDEndpoint + "?" + params.Encode() +} + +func (p steamProvider) ValidateCallback(ctx context.Context, r *http.Request) (*UserProvidedData, error) { + if mode := r.FormValue("openid.mode"); mode != "id_res" { + return nil, fmt.Errorf("Invalid openid.mode: %s", mode) + } + + // Verify signature + params := url.Values{} + params.Add("openid.ns", "http://specs.openid.net/auth/2.0") + params.Add("openid.mode", "check_authentication") + + // Copy all openid.* parameters + for key, values := range r.URL.Query() { + if strings.HasPrefix(key, "openid.") { + params[key] = values + } + } + + // Verify with Steam + resp, err := http.PostForm(steamOpenIDEndpoint, params) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if !strings.Contains(string(body), "is_valid:true") { + return nil, errors.New("Invalid Steam authentication response") + } + + // Extract Steam ID + matches := steamIDRegex.FindStringSubmatch(r.FormValue("openid.claimed_id")) + if len(matches) != 2 { + return nil, errors.New("Invalid Steam ID format") + } + + steamID := matches[1] + + data := &UserProvidedData{ + Metadata: &Claims{ + Issuer: steamOpenIDEndpoint, + Subject: steamID, + ProviderId: steamID, + }, + } + + return data, nil +} \ No newline at end of file diff --git a/internal/api/provider/steam_test.go b/internal/api/provider/steam_test.go new file mode 100644 index 0000000000..2192a9e6a5 --- /dev/null +++ b/internal/api/provider/steam_test.go @@ -0,0 +1,27 @@ +package provider + +import ( + "context" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestSteamProvider(t *testing.T) { + provider, err := NewSteamProvider(conf.OAuthProviderConfiguration{ + Realm: "http://localhost:9999", + }) + require.NoError(t, err) + + authURL := provider.GetAuthorizationURL("test-state") + require.Contains(t, authURL, "steamcommunity.com/openid/login") + require.Contains(t, authURL, "openid.mode=checkid_setup") + require.Contains(t, authURL, "openid.realm=http://localhost:9999") + + u, err := url.Parse(authURL) + require.NoError(t, err) + require.Equal(t, "http://specs.openid.net/auth/2.0/identifier_select", u.Query().Get("openid.claimed_id")) +} \ No newline at end of file diff --git a/internal/api/recover.go b/internal/api/recover.go index 7c03c3246e..4e398041ad 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -48,10 +48,10 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { user, err = models.FindUserByEmailAndAudience(db, params.Email, aud) if err != nil { - if models.IsNotFoundError(err) { - return sendJSON(w, http.StatusOK, map[string]string{}) + if user != nil && (user.IsSSOUser || user.AppMetaData["provider"] == "steam") { + return unprocessableEntityError(ErrorCodeSSOUser, "Password recovery is not supported for this type of account") } - return internalServerError("Unable to process request").WithInternalError(err) + return oauthError("invalid_request", "Recovery requires a valid email") } if isPKCEFlow(flowType) { if _, err := generateFlowState(db, models.Recovery.String(), models.Recovery, params.CodeChallengeMethod, params.CodeChallenge, &(user.ID)); err != nil { diff --git a/internal/api/settings.go b/internal/api/settings.go index bc2f38692d..4380c6d9e8 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -28,6 +28,7 @@ type ProviderSettings struct { Email bool `json:"email"` Phone bool `json:"phone"` Zoom bool `json:"zoom"` + Steam bool `json:"steam"` } type Settings struct { @@ -69,6 +70,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Email: config.External.Email.Enabled, Phone: config.External.Phone.Enabled, Zoom: config.External.Zoom.Enabled, + Steam: config.External.Steam.Enabled, }, DisableSignup: config.DisableSignup, MailerAutoconfirm: config.Mailer.Autoconfirm, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 767bcf7846..bb58a4c216 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -46,6 +46,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Twitch) require.True(t, p.WorkOS) require.True(t, p.Zoom) + require.True(t, p.Steam) } diff --git a/internal/api/user.go b/internal/api/user.go index 8588ce3192..9b6f2a65b2 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -180,6 +180,11 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } } + // Prevent email changes for providers that don't support email + if user.AppMetaData["provider"] == "steam" && params.Email != "" { + return unprocessableEntityError(ErrorCodeEmailNotSupported, "Email management is not supported for this type of account") + } + err := db.Transaction(func(tx *storage.Connection) error { var terr error if params.Password != nil { diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index c4d910d991..b1b7bd3f41 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -66,6 +66,7 @@ type OAuthProviderConfiguration struct { ApiURL string `json:"api_url" split_words:"true"` Enabled bool `json:"enabled"` SkipNonceCheck bool `json:"skip_nonce_check" split_words:"true"` + Realm string `split_words:"true"` } type AnonymousProviderConfiguration struct { @@ -339,6 +340,7 @@ type ProviderConfiguration struct { RedirectURL string `json:"redirect_url"` AllowedIdTokenIssuers []string `json:"allowed_id_token_issuers" split_words:"true"` FlowStateExpiryDuration time.Duration `json:"flow_state_expiry_duration" split_words:"true"` + Steam OAuthProviderConfiguration `json:"steam"` } type SMTPConfiguration struct {