diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 000000000..f3d245c6f --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,44 @@ +name: Build Docker Image + +on: + workflow_dispatch: + push: + branches: + - "**" + paths: + - "**" + +permissions: + actions: read + checks: write + contents: write + deployments: write + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute short sha + id: vars + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Build and push image + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository }}/supabase-auth:latest + ghcr.io/${{ github.repository }}/supabase-auth:${{ steps.vars.outputs.sha_short }} diff --git a/Dockerfile.dev b/Dockerfile.dev index 99a8c0d5c..9405f3838 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -3,7 +3,7 @@ ENV GO111MODULE=on ENV CGO_ENABLED=0 ENV GOOS=linux -RUN apk add --no-cache make git bash +RUN apk add --no-cache make git bash build-base WORKDIR /go/src/github.com/supabase/auth diff --git a/Makefile b/Makefile index 14d41aa1c..6401f9ec4 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ migrate_test: ## Run database migrations for test. hack/migrate.sh postgres test: build ## Run tests. - go test $(CHECK_FILES) -coverprofile=coverage.out -coverpkg ./... -p 1 -race -v -count=1 + POSTGRES_HOST=$${POSTGRES_HOST:-localhost} go test $(CHECK_FILES) -coverprofile=coverage.out -coverpkg ./... -p 1 -race -v -count=1 ./hack/coverage.sh vet: # Vet the code diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index d5c6173c2..7f895f6f1 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -10,6 +10,8 @@ services: - '9100:9100' environment: - GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/supabase/auth/migrations + - POSTGRES_HOST=postgres + - CGO_ENABLED=1 volumes: - ./:/go/src/github.com/supabase/auth command: CompileDaemon --build="make build" --directory=/go/src/github.com/supabase/auth --recursive=true -pattern="(.+\.go|.+\.env)" -exclude=auth -exclude=auth-arm64 -exclude=.env --command="/go/src/github.com/supabase/auth/auth -c=.env.docker" diff --git a/hack/migrate.sh b/hack/migrate.sh index 2d1f0e5e8..8fee7839d 100755 --- a/hack/migrate.sh +++ b/hack/migrate.sh @@ -6,7 +6,8 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" DATABASE="$DIR/database.yml" export GOTRUE_DB_DRIVER="postgres" -export GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/$DB_ENV" +HOST=${POSTGRES_HOST:-localhost} +export GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:root@$HOST:5432/$DB_ENV" export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations go run main.go migrate -c $DIR/test.env diff --git a/internal/api/external.go b/internal/api/external.go index 8392797d5..37b389e8c 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -674,6 +674,9 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide case "spotify": pConfig = config.External.Spotify p, err = provider.NewSpotifyProvider(pConfig, scopes) + case "seznam": + pConfig = config.External.Seznam + p, err = provider.NewSeznamProvider(pConfig, scopes) case "slack": pConfig = config.External.Slack p, err = provider.NewSlackProvider(pConfig, scopes) diff --git a/internal/api/provider/seznam.go b/internal/api/provider/seznam.go new file mode 100644 index 000000000..d530912b0 --- /dev/null +++ b/internal/api/provider/seznam.go @@ -0,0 +1,109 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +// Seznam provider constants +const ( + defaultSeznamAuthURL = "https://login.szn.cz/api/v1/oauth/auth" + defaultSeznamTokenURL = "https://login.szn.cz/api/v1/oauth/token" + defaultSeznamUserURL = "login.szn.cz/api/v1/user" +) + +type seznamProvider struct { + *oauth2.Config + APIURL string +} + +type seznamUser struct { + ID string `json:"oauth_user_id"` + Email string `json:"email"` + Name string `json:"firstname"` + FamilyName string `json:"lastname"` + AvatarURL string `json:"avatar_url"` + EmailVerified bool `json:"email_verified"` // This might need to be checked against real response, docs claim 'identity' scope provides email +} + +// NewSeznamProvider creates a Seznam OAuth2 identity provider. +func NewSeznamProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + oauthScopes := []string{ + "identity", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + apiPath := chooseHost(ext.URL, defaultSeznamUserURL) + + return &seznamProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: defaultSeznamAuthURL, + TokenURL: defaultSeznamTokenURL, + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + APIURL: apiPath, + }, nil +} + +func (g seznamProvider) GetOAuthToken(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(ctx, code, opts...) +} + +func (g seznamProvider) RequiresPKCE() bool { + return true +} + +func (g seznamProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u seznamUser + if err := makeRequest(ctx, tok, g.Config, g.APIURL, &u); err != nil { + return nil, err + } + + if u.ID == "" { + return nil, errors.New("user id was not returned from Seznam API") + } + + var data UserProvidedData + + if u.Email != "" { + data.Emails = append(data.Emails, Email{ + Email: u.Email, + Verified: true, // Seznam verifies emails + Primary: true, + }) + } + + data.Metadata = &Claims{ + Issuer: g.APIURL, + Subject: u.ID, + Name: strings.TrimSpace(u.Name + " " + u.FamilyName), + GivenName: u.Name, + FamilyName: u.FamilyName, + Picture: u.AvatarURL, + Email: u.Email, + EmailVerified: true, // Seznam verifies emails + + // To be deprecated + AvatarURL: u.AvatarURL, + FullName: strings.TrimSpace(u.Name + " " + u.FamilyName), + ProviderId: u.ID, + } + + return &data, nil +} diff --git a/internal/api/provider/seznam_test.go b/internal/api/provider/seznam_test.go new file mode 100644 index 000000000..4a0351f26 --- /dev/null +++ b/internal/api/provider/seznam_test.go @@ -0,0 +1,83 @@ +package provider + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +func TestSeznam(t *testing.T) { + t.Run("GetUserData", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer token", r.Header.Get("Authorization")) + w.Write([]byte(`{"oauth_user_id":"test_id","email":"test@example.com","firstname":"Test","lastname":"User","avatar_url":"http://example.com/avatar"}`)) + })) + defer server.Close() + + p := seznamProvider{ + Config: &oauth2.Config{}, + APIURL: server.URL, + } + + data, err := p.GetUserData(context.Background(), &oauth2.Token{ + AccessToken: "token", + }) + assert.NoError(t, err) + assert.Equal(t, "test@example.com", data.Emails[0].Email) + assert.Equal(t, "test_id", data.Metadata.Subject) + assert.Equal(t, "Test User", data.Metadata.Name) + assert.Equal(t, "http://example.com/avatar", data.Metadata.Picture) + }) + + t.Run("GetUserData Error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + p := seznamProvider{ + Config: &oauth2.Config{}, + APIURL: server.URL, + } + + _, err := p.GetUserData(context.Background(), &oauth2.Token{ + AccessToken: "token", + }) + assert.Error(t, err) + }) + + t.Run("GetUserData Missing ID", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"email":"test@example.com"}`)) + })) + defer server.Close() + + p := seznamProvider{ + Config: &oauth2.Config{}, + APIURL: server.URL, + } + + _, err := p.GetUserData(context.Background(), &oauth2.Token{ + AccessToken: "token", + }) + assert.Error(t, err) + assert.Equal(t, errors.New("user id was not returned from Seznam API"), err) + }) + + t.Run("NewSeznamProvider", func(t *testing.T) { + p, err := NewSeznamProvider(conf.OAuthProviderConfiguration{ + ClientID: []string{"client_id"}, + Secret: "secret", + RedirectURI: "http://localhost/callback", + URL: "http://example.com", + }, "") + assert.NoError(t, err) + assert.NotNil(t, p) + }) +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 3e397be69..a70144432 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -430,6 +430,7 @@ type ProviderConfiguration struct { Keycloak OAuthProviderConfiguration `json:"keycloak"` Linkedin OAuthProviderConfiguration `json:"linkedin"` LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` + Seznam OAuthProviderConfiguration `json:"seznam"` Spotify OAuthProviderConfiguration `json:"spotify"` Slack OAuthProviderConfiguration `json:"slack"` SlackOIDC OAuthProviderConfiguration `json:"slack_oidc" envconfig:"SLACK_OIDC"`