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
7 changes: 7 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
5 changes: 5 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
213 changes: 213 additions & 0 deletions internal/api/external_line_test.go
Original file line number Diff line number Diff line change
@@ -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", "")
}
97 changes: 97 additions & 0 deletions internal/api/provider/line.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions internal/api/provider/line_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading