Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
4 changes: 4 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
216 changes: 216 additions & 0 deletions internal/api/external_line_test.go
Original file line number Diff line number Diff line change
@@ -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", "")
}
140 changes: 140 additions & 0 deletions internal/api/provider/line.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading