From 97e5f30d28de298957ff2f6895ed6341e1e2ee02 Mon Sep 17 00:00:00 2001 From: k-kawasaki Date: Mon, 15 Jun 2026 09:16:10 +0900 Subject: [PATCH] feat(provider): add LINE Login provider Add LINE Login as an external OAuth provider. The user's profile (name, picture) and email are returned as claims in the OIDC ID token, so the provider reads them directly from the ID token instead of calling a separate userinfo endpoint. Although LINE exposes an OIDC discovery document, it does not sign the ID token with the ES256/JWKS keys it advertises; it signs with HS256 using the channel secret. The ID token is therefore verified with the channel secret (HS256), validating the issuer (https://access.line.me), audience (channel ID) and expiry. Web (authorization-code) flow only; native id_token sign-in is out of scope. --- example.env | 6 + hack/test.env | 4 + internal/api/external.go | 3 + internal/api/external_line_test.go | 216 +++++++++++++++++++++++++++++ internal/api/provider/line.go | 140 +++++++++++++++++++ internal/api/settings.go | 2 + internal/api/settings_test.go | 1 + internal/conf/configuration.go | 1 + 8 files changed, 373 insertions(+) create mode 100644 internal/api/external_line_test.go create mode 100644 internal/api/provider/line.go diff --git a/example.env b/example.env index 92bb7d917e..e19cda1107 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 44dd645326..17bcd42cfd 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 75bad9bc58..bf292c2240 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 0000000000..c54f1198c0 --- /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 0000000000..cb8d9e1be2 --- /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 be7ac504f8..c689fb003d 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 ca44d445b4..9ee18a0b49 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 9b0ec63ba4..1d065ab825 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"`