diff --git a/example.env b/example.env index b98824643..2d19d914d 100644 --- a/example.env +++ b/example.env @@ -169,6 +169,13 @@ GOTRUE_EXTERNAL_KEYCLOAK_SECRET="" GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI="http://localhost:9999/callback" GOTRUE_EXTERNAL_KEYCLOAK_URL="https://keycloak.example.com/auth/realms/myrealm" +# LINE OAuth config +GOTRUE_EXTERNAL_LINE_ENABLED="false" +GOTRUE_EXTERNAL_LINE_CLIENT_ID="" +GOTRUE_EXTERNAL_LINE_SECRET="" +GOTRUE_EXTERNAL_LINE_REDIRECT_URI="http://localhost:9999/callback" +GOTRUE_EXTERNAL_LINE_EMAIL_OPTIONAL="true" + # LinkedIn OAuth config GOTRUE_EXTERNAL_LINKEDIN_ENABLED="true" GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID="" diff --git a/hack/test.env b/hack/test.env index 97a01ba03..d1b563fe5 100644 --- a/hack/test.env +++ b/hack/test.env @@ -56,6 +56,11 @@ GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=testclientid GOTRUE_EXTERNAL_KEYCLOAK_SECRET=testsecret GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_KEYCLOAK_URL=https://keycloak.example.com/auth/realms/myrealm +GOTRUE_EXTERNAL_LINE_ENABLED=true +GOTRUE_EXTERNAL_LINE_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_LINE_SECRET=testsecret +GOTRUE_EXTERNAL_LINE_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_LINE_EMAIL_OPTIONAL=true GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=testclientid GOTRUE_EXTERNAL_LINKEDIN_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index 9611c24c2..9a3c690d6 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -629,6 +629,9 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide case "keycloak": pConfig = config.External.Keycloak p, err = provider.NewKeycloakProvider(pConfig, scopes) + case "line": + pConfig = config.External.Line + p, err = provider.NewLineProvider(pConfig, scopes) case "linkedin": pConfig = config.External.Linkedin p, err = provider.NewLinkedinProvider(pConfig, scopes) diff --git a/internal/api/external_line_test.go b/internal/api/external_line_test.go new file mode 100644 index 000000000..88aa20cce --- /dev/null +++ b/internal/api/external_line_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + + "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/models" +) + +func (ts *ExternalTestSuite) TestSignupExternalLine() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=line", 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(ts.Config.External.Line.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Line.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + + 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("line", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func LineTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, emails string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth2/v2.1/token": + *tokenCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.Line.RedirectURI, r.FormValue("redirect_uri")) + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"line_token","expires_in":100000}`) + case "/oauth2/v2.1/userinfo": + *userCount++ + var emailList []provider.Email + if err := json.Unmarshal([]byte(emails), &emailList); err != nil { + ts.Fail("Invalid email json %s", emails) + } + + var email *provider.Email + for i := range emailList { + if emailList[i].Email != "" { + email = &emailList[i] + break + } + } + + w.Header().Add("Content-Type", "application/json") + if email != nil { + fmt.Fprintf(w, ` + { + "sub":"123", + "name":"Line Test", + "picture":"http://example.com/avatar", + "email":"%v", + "email_verified": %v + }`, email.Email, email.Verified) + } else { + fmt.Fprint(w, ` + { + "sub":"123", + "name":"Line Test", + "picture":"http://example.com/avatar" + }`) + } + default: + w.WriteHeader(http.StatusInternalServerError) + ts.Fail("unknown line oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.Line.URL = server.URL + ts.Config.External.Line.ApiURL = server.URL + ts.Config.External.Line.EmailOptional = false + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalLine_AuthorizationCode() { + tokenCount, userCount := 0, 0 + code := "authcode" + emails := `[{"email":"line@example.com", "primary": true, "verified": true}]` + server := LineTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "line@example.com", "Line Test", "123", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestSignupExternalLineDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + + tokenCount, userCount := 0, 0 + code := "authcode" + emails := `[{"email":"line@example.com", "primary": true, "verified": true}]` + server := LineTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "line@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalLineDisableSignupErrorWhenEmptyEmail() { + ts.Config.DisableSignup = true + + tokenCount, userCount := 0, 0 + code := "authcode" + emails := `[{"primary": true, "verified": true}]` + server := LineTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + + assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "line@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalLineDisableSignupSuccessWithPrimaryEmail() { + ts.Config.DisableSignup = true + + ts.createUser("123", "line@example.com", "Line Test", "http://example.com/avatar", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + emails := `[{"email":"line@example.com", "primary": true, "verified": true}]` + server := LineTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "line@example.com", "Line Test", "123", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalLineSuccessWhenMatchingToken() { + ts.createUser("123", "line@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + emails := `[{"email":"line@example.com", "primary": true, "verified": true}]` + server := LineTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + u := performAuthorization(ts, "line", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "line@example.com", "Line Test", "123", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalLineErrorWhenEmailDoesntMatch() { + ts.createUser("123", "line@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + emails := `[{"email":"other@example.com", "primary": true, "verified": true}]` + server := LineTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + u := performAuthorization(ts, "line", code, "invite_token") + + assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalLineErrorWhenVerifiedFalse() { + ts.Config.Mailer.AllowUnverifiedEmailSignIns = false + + tokenCount, userCount := 0, 0 + code := "authcode" + emails := `[{"email":"line@example.com", "primary": true, "verified": false}]` + server := LineTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + + assertAuthorizationFailure(ts, u, "Unverified email with line. A confirmation email has been sent to your line email", "access_denied", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalLineErrorWhenUserBanned() { + tokenCount, userCount := 0, 0 + code := "authcode" + emails := `[{"email":"line@example.com", "primary": true, "verified": true}]` + server := LineTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "line@example.com", "Line Test", "123", "http://example.com/avatar") + + user, err := models.FindUserByEmailAndAudience(ts.API.db, "line@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + t := time.Now().Add(24 * time.Hour) + user.BannedUntil = &t + require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until")) + + u = performAuthorization(ts, "line", code, "") + + assertAuthorizationFailure(ts, u, "User is banned", "access_denied", "") +} diff --git a/internal/api/provider/line.go b/internal/api/provider/line.go new file mode 100644 index 000000000..e225a6352 --- /dev/null +++ b/internal/api/provider/line.go @@ -0,0 +1,97 @@ +package provider + +import ( + "context" + "strings" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +const ( + defaultLineAuthBase = "access.line.me" + defaultLineAPIBase = "api.line.me" + IssuerLine = "https://access.line.me" +) + +type lineProvider struct { + *oauth2.Config + APIHost string +} + +type lineUser struct { + Subject string `json:"sub"` + Name string `json:"name"` + Picture string `json:"picture"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +func NewLineProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + authHost := chooseHost(ext.URL, defaultLineAuthBase) + apiHost := chooseHost(ext.ApiURL, defaultLineAPIBase) + + oauthScopes := []string{ + "profile", + "openid", + "email", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + return &lineProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: authHost + "/oauth2/v2.1/authorize", + TokenURL: apiHost + "/oauth2/v2.1/token", + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + APIHost: apiHost, + }, nil +} + +func (p lineProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return p.Exchange(context.Background(), code) +} + +func (p lineProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u lineUser + if err := makeRequest(ctx, tok, p.Config, p.APIHost+"/oauth2/v2.1/userinfo", &u); err != nil { + return nil, err + } + + data := &UserProvidedData{} + if u.Email != "" { + data.Emails = []Email{{ + Email: u.Email, + Verified: u.EmailVerified, + Primary: true, + }} + } + + data.Metadata = &Claims{ + Issuer: IssuerLine, + Subject: u.Subject, + Name: u.Name, + PreferredUsername: u.Name, + Picture: u.Picture, + Email: u.Email, + EmailVerified: u.EmailVerified, + + AvatarURL: u.Picture, + FullName: u.Name, + ProviderId: u.Subject, + } + + return data, nil +} diff --git a/internal/api/provider/line_test.go b/internal/api/provider/line_test.go new file mode 100644 index 000000000..f6ed4937f --- /dev/null +++ b/internal/api/provider/line_test.go @@ -0,0 +1,39 @@ +package provider + +import ( + "testing" + + "github.com/supabase/auth/internal/conf" +) + +func TestNewLineProvider_DefaultApiHostFallback(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + Enabled: true, + ClientID: []string{"client-id"}, + Secret: "client-secret", + RedirectURI: "https://example.com/callback", + URL: "https://access.line.me", + } + + provider, err := NewLineProvider(cfg, "") + if err != nil { + t.Fatalf("NewLineProvider returned error: %v", err) + } + + p, ok := provider.(*lineProvider) + if !ok { + t.Fatalf("expected *lineProvider, got %T", provider) + } + + if expected := "https://api.line.me"; p.APIHost != expected { + t.Fatalf("unexpected APIHost: got %q want %q", p.APIHost, expected) + } + + if expected := "https://api.line.me/oauth2/v2.1/token"; p.Endpoint.TokenURL != expected { + t.Fatalf("unexpected TokenURL: got %q want %q", p.Endpoint.TokenURL, expected) + } + + if expected := "https://access.line.me/oauth2/v2.1/authorize"; p.Endpoint.AuthURL != expected { + t.Fatalf("unexpected AuthURL: got %q want %q", p.Endpoint.AuthURL, expected) + } +} diff --git a/internal/api/provider/oidc.go b/internal/api/provider/oidc.go index 7dd976780..773dbbc24 100644 --- a/internal/api/provider/oidc.go +++ b/internal/api/provider/oidc.go @@ -58,6 +58,8 @@ func ParseIDToken(ctx context.Context, provider *oidc.Provider, config *oidc.Con token, data, err = parseLinkedinIDToken(token) case IssuerKakao: token, data, err = parseKakaoIDToken(token) + case IssuerLine: + token, data, err = parseLineIDToken(token) case IssuerVercelMarketplace: token, data, err = parseVercelMarketplaceIDToken(token) case IssuerFacebook: @@ -401,6 +403,49 @@ func parseKakaoIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, e return token, &data, nil } +type LineIDTokenClaims struct { + jwt.RegisteredClaims + + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func parseLineIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { + var claims LineIDTokenClaims + if err := token.Claims(&claims); err != nil { + return nil, nil, err + } + + var data UserProvidedData + + if claims.Email != "" { + data.Emails = append(data.Emails, Email{ + Email: claims.Email, + Verified: claims.EmailVerified, + Primary: true, + }) + } + + data.Metadata = &Claims{ + Issuer: token.Issuer, + Subject: token.Subject, + Name: claims.Name, + PreferredUsername: claims.Name, + Picture: claims.Picture, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + ProviderId: token.Subject, + + // To be deprecated + AvatarURL: claims.Picture, + FullName: claims.Name, + } + + return token, &data, nil +} + type VercelMarketplaceIDTokenClaims struct { jwt.RegisteredClaims diff --git a/internal/api/settings.go b/internal/api/settings.go index 7601f6f40..9cd9a905c 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -17,6 +17,7 @@ type ProviderSettings struct { Google bool `json:"google"` Keycloak bool `json:"keycloak"` Kakao bool `json:"kakao"` + Line bool `json:"line"` Linkedin bool `json:"linkedin"` LinkedinOIDC bool `json:"linkedin_oidc"` Notion bool `json:"notion"` @@ -59,6 +60,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Google: config.External.Google.Enabled, Kakao: config.External.Kakao.Enabled, Keycloak: config.External.Keycloak.Enabled, + Line: config.External.Line.Enabled, Linkedin: config.External.Linkedin.Enabled, LinkedinOIDC: config.External.LinkedinOIDC.Enabled, Notion: config.External.Notion.Enabled, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index ca44d445b..9ee18a0b4 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -40,6 +40,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Google) require.True(t, p.Kakao) require.True(t, p.Keycloak) + require.True(t, p.Line) require.True(t, p.Linkedin) require.True(t, p.LinkedinOIDC) require.True(t, p.GitHub) diff --git a/internal/api/token_oidc.go b/internal/api/token_oidc.go index e62940abc..b5fa9c341 100644 --- a/internal/api/token_oidc.go +++ b/internal/api/token_oidc.go @@ -98,6 +98,12 @@ func (p *IdTokenGrantParams) getProvider(ctx context.Context, config *conf.Globa issuer = provider.IssuerKakao acceptableClientIDs = append(acceptableClientIDs, config.External.Kakao.ClientID...) + case p.Provider == "line" || p.Issuer == provider.IssuerLine: + cfg = &config.External.Line + providerType = "line" + issuer = provider.IssuerLine + acceptableClientIDs = append(acceptableClientIDs, config.External.Line.ClientID...) + case p.Provider == "vercel_marketplace" || p.Issuer == provider.IssuerVercelMarketplace: cfg = &config.External.VercelMarketplace providerType = "vercel_marketplace" diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 3f4bbf833..443a5e350 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -427,6 +427,7 @@ type ProviderConfiguration struct { Kakao OAuthProviderConfiguration `json:"kakao"` Notion OAuthProviderConfiguration `json:"notion"` Keycloak OAuthProviderConfiguration `json:"keycloak"` + Line OAuthProviderConfiguration `json:"line"` Linkedin OAuthProviderConfiguration `json:"linkedin"` LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` Spotify OAuthProviderConfiguration `json:"spotify"` diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 8ce7d9b3f..2c08d8577 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -128,6 +128,13 @@ GOTRUE_EXTERNAL_KAKAO_CLIENT_ID="" GOTRUE_EXTERNAL_KAKAO_SECRET="" GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback" +# LINE OAuth config +GOTRUE_EXTERNAL_LINE_ENABLED="false" +GOTRUE_EXTERNAL_LINE_CLIENT_ID="" +GOTRUE_EXTERNAL_LINE_SECRET="" +GOTRUE_EXTERNAL_LINE_REDIRECT_URI="http://localhost:9999/callback" +GOTRUE_EXTERNAL_LINE_EMAIL_OPTIONAL="true" + # Notion OAuth config GOTRUE_EXTERNAL_NOTION_ENABLED="false" GOTRUE_EXTERNAL_NOTION_CLIENT_ID="" diff --git a/internal/reloader/testdata/60_example_newline.env b/internal/reloader/testdata/60_example_newline.env index 3be660093..1d7eb2699 100644 --- a/internal/reloader/testdata/60_example_newline.env +++ b/internal/reloader/testdata/60_example_newline.env @@ -153,6 +153,13 @@ GOTRUE_EXTERNAL_KEYCLOAK_SECRET="" GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI="http://localhost:9999/callback" GOTRUE_EXTERNAL_KEYCLOAK_URL="https://keycloak.example.com/auth/realms/myrealm" +# LINE OAuth config +GOTRUE_EXTERNAL_LINE_ENABLED="false" +GOTRUE_EXTERNAL_LINE_CLIENT_ID="" +GOTRUE_EXTERNAL_LINE_SECRET="" +GOTRUE_EXTERNAL_LINE_REDIRECT_URI="http://localhost:9999/callback" +GOTRUE_EXTERNAL_LINE_EMAIL_OPTIONAL="true" + # Linkedin OAuth config GOTRUE_EXTERNAL_LINKEDIN_ENABLED="true" GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=""