diff --git a/example.env b/example.env index 92bb7d917..e19cda110 100644 --- a/example.env +++ b/example.env @@ -138,6 +138,12 @@ 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" + # Notion OAuth config GOTRUE_EXTERNAL_NOTION_ENABLED="false" GOTRUE_EXTERNAL_NOTION_CLIENT_ID="" diff --git a/hack/test.env b/hack/test.env index 44dd64532..17bcd42cf 100644 --- a/hack/test.env +++ b/hack/test.env @@ -51,6 +51,10 @@ GOTRUE_EXTERNAL_KAKAO_ENABLED=true GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=testclientid GOTRUE_EXTERNAL_KAKAO_SECRET=testsecret GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI=https://identity.services.netlify.com/callback +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_KEYCLOAK_ENABLED=true GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=testclientid GOTRUE_EXTERNAL_KEYCLOAK_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index 75bad9bc5..bf292c224 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -638,6 +638,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..c54f1198c --- /dev/null +++ b/internal/api/external_line_test.go @@ -0,0 +1,216 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/models" +) + +const ( + lineUser string = `{"name":"LINE Test","email":"line@example.com","sub":"linetestid","picture":"http://example.com/avatar"}` + lineUserNoEmail string = `{"name":"LINE Test","sub":"linetestid","picture":"http://example.com/avatar"}` +) + +// mintLineIDToken builds an ID token the way LINE does: an HS256 JWT signed with +// the channel secret (GOTRUE_EXTERNAL_LINE_SECRET in hack/test.env). +func mintLineIDToken(ts *ExternalTestSuite, user string) string { + var fields struct { + Sub string `json:"sub,omitempty"` + Name string `json:"name,omitempty"` + Picture string `json:"picture,omitempty"` + Email string `json:"email,omitempty"` + } + if err := json.Unmarshal([]byte(user), &fields); err != nil { + panic(err) + } + + now := time.Now() + claims := jwt.MapClaims{ + "iss": provider.IssuerLINE, + "sub": fields.Sub, + "aud": ts.Config.External.Line.ClientID[0], + "exp": now.Add(time.Hour).Unix(), + "iat": now.Unix(), + } + if fields.Name != "" { + claims["name"] = fields.Name + } + if fields.Picture != "" { + claims["picture"] = fields.Picture + } + if fields.Email != "" { + claims["email"] = fields.Email + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(ts.Config.External.Line.Secret)) + if err != nil { + panic(err) + } + return signed +} + +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")) + ts.Equal("openid profile email", q.Get("scope")) + + assertValidOAuthState(ts, q.Get("state"), "line") +} + +func LineTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, code string, user 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.Fprintf(w, `{"access_token":"line_token","expires_in":100000,"id_token":%q}`, mintLineIDToken(ts, user)) + default: + w.WriteHeader(500) + ts.Fail("unknown line oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.Line.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalLine_AuthorizationCode() { + ts.Config.DisableSignup = false + tokenCount := 0 + code := "authcode" + server := LineTestSignupSetup(ts, &tokenCount, code, lineUser) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, -1, "line@example.com", "LINE Test", "linetestid", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestSignupExternalLineDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + tokenCount := 0 + code := "authcode" + server := LineTestSignupSetup(ts, &tokenCount, code, lineUser) + 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) TestSignupExternalLineDisableSignupErrorWhenNoEmail() { + ts.Config.DisableSignup = true + tokenCount := 0 + code := "authcode" + server := LineTestSignupSetup(ts, &tokenCount, code, lineUserNoEmail) + 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("linetestid", "line@example.com", "LINE Test", "http://example.com/avatar", "") + + tokenCount := 0 + code := "authcode" + server := LineTestSignupSetup(ts, &tokenCount, code, lineUser) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, -1, "line@example.com", "LINE Test", "linetestid", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalLineSuccessWhenMatchingToken() { + // name and avatar should be populated from LINE's ID token + ts.createUser("linetestid", "line@example.com", "", "", "invite_token") + + tokenCount := 0 + code := "authcode" + server := LineTestSignupSetup(ts, &tokenCount, code, lineUser) + defer server.Close() + + u := performAuthorization(ts, "line", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, -1, "line@example.com", "LINE Test", "linetestid", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalLineErrorWhenNoMatchingToken() { + tokenCount := 0 + code := "authcode" + server := LineTestSignupSetup(ts, &tokenCount, code, lineUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "line", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalLineErrorWhenWrongToken() { + ts.createUser("linetestid", "line@example.com", "", "", "invite_token") + + tokenCount := 0 + code := "authcode" + server := LineTestSignupSetup(ts, &tokenCount, code, lineUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "line", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalLineErrorWhenEmailDoesntMatch() { + ts.createUser("linetestid", "line@example.com", "", "", "invite_token") + + tokenCount := 0 + code := "authcode" + lineUserWrongEmail := `{"name":"LINE Test","email":"other@example.com","sub":"linetestid","picture":"http://example.com/avatar"}` + server := LineTestSignupSetup(ts, &tokenCount, code, lineUserWrongEmail) + 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) TestSignupExternalLineErrorWhenUserBanned() { + tokenCount := 0 + code := "authcode" + server := LineTestSignupSetup(ts, &tokenCount, code, lineUser) + defer server.Close() + + u := performAuthorization(ts, "line", code, "") + assertAuthorizationSuccess(ts, u, tokenCount, -1, "line@example.com", "LINE Test", "linetestid", "http://example.com/avatar") + + user, err := models.FindUserByEmailAndAudience(ts.API.db, "line@example.com", ts.Config.JWT.Aud) + ts.Require().NoError(err) + t := time.Now().Add(24 * time.Hour) + user.BannedUntil = &t + ts.Require().NoError(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..cb8d9e1be --- /dev/null +++ b/internal/api/provider/line.go @@ -0,0 +1,140 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +// IssuerLINE is the issuer value found in LINE Login's OpenID Connect ID tokens. +const IssuerLINE = "https://access.line.me" + +const ( + // defaultLineAuthBase hosts the authorization endpoint. + defaultLineAuthBase = "access.line.me" + // defaultLineAPIBase hosts the token endpoint. + defaultLineAPIBase = "api.line.me" +) + +type lineProvider struct { + *oauth2.Config + + clientID string + secret string +} + +type lineIDTokenClaims struct { + jwt.RegisteredClaims + + Name string `json:"name"` + Picture string `json:"picture"` + Email string `json:"email"` +} + +// NewLineProvider creates a LINE account provider. +// +// LINE Login is an OAuth 2.0 / OpenID Connect provider. The user's profile +// (name, picture) and email are returned as claims in the ID token. Unlike most +// OIDC providers, LINE signs its ID token using HS256 with the channel secret as +// the key (despite advertising ES256 in its discovery document), so the token is +// verified with the channel secret rather than via the provider's JWKS. +func NewLineProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + oauthScopes := []string{ + "openid", + "profile", + "email", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + authHost := chooseHost(ext.URL, defaultLineAuthBase) + tokenHost := chooseHost(ext.URL, defaultLineAPIBase) + + return &lineProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthStyle: oauth2.AuthStyleInParams, + AuthURL: authHost + "/oauth2/v2.1/authorize", + TokenURL: tokenHost + "/oauth2/v2.1/token", + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + clientID: ext.ClientID[0], + secret: ext.Secret, + }, nil +} + +func (p lineProvider) GetOAuthToken(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.Exchange(ctx, code, opts...) +} + +func (p lineProvider) RequiresPKCE() bool { + return false +} + +func (p lineProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + rawIDToken, ok := tok.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return nil, fmt.Errorf("line: no id_token present in token response") + } + + var claims lineIDTokenClaims + // LINE signs ID tokens with HS256 using the channel secret as the key. The + // token is received directly from LINE's token endpoint over TLS during the + // authorization code exchange. + if _, err := jwt.ParseWithClaims(rawIDToken, &claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("line: unexpected signing method: %v", t.Header["alg"]) + } + return []byte(p.secret), nil + }, + jwt.WithValidMethods([]string{"HS256"}), + jwt.WithIssuer(IssuerLINE), + jwt.WithAudience(p.clientID), + ); err != nil { + return nil, fmt.Errorf("line: failed to verify id_token: %w", err) + } + + data := &UserProvidedData{} + + if claims.Email != "" { + data.Emails = []Email{ + { + Email: claims.Email, + // LINE only returns the email claim once the user has granted + // the email permission, and only verified emails are returned. + Verified: true, + Primary: true, + }, + } + } + + data.Metadata = &Claims{ + Issuer: IssuerLINE, + Subject: claims.Subject, + Name: claims.Name, + PreferredUsername: claims.Name, + Picture: claims.Picture, + ProviderId: claims.Subject, + + // To be deprecated + AvatarURL: claims.Picture, + FullName: claims.Name, + UserNameKey: claims.Name, + } + + return data, nil +} diff --git a/internal/api/settings.go b/internal/api/settings.go index be7ac504f..c689fb003 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"` @@ -60,6 +61,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/conf/configuration.go b/internal/conf/configuration.go index 9b0ec63ba..1d065ab82 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -463,6 +463,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"`