Skip to content

Commit

Permalink
feat: add third-party auth support for local dev (#2595)
Browse files Browse the repository at this point in the history
* feat: add third-party auth support for local dev

* fix: handle timeout and cancellation when resolving jwk

---------

Co-authored-by: Qiao Han <[email protected]>
  • Loading branch information
hf and sweatybridge authored Aug 13, 2024
1 parent 309980e commit 21a0a58
Show file tree
Hide file tree
Showing 3 changed files with 244 additions and 1 deletion.
9 changes: 8 additions & 1 deletion internal/start/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ func run(p utils.Program, ctx context.Context, fsys afero.Fs, excludedContainers
excluded[name] = true
}

jwks, err := utils.Config.Auth.ResolveJWKS(ctx)
if err != nil {
return err
}

// Start Postgres.
w := utils.StatusWriter{Program: p}
if dbConfig.Host == utils.DbId {
Expand Down Expand Up @@ -737,6 +742,7 @@ EOF
"DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime",
"DB_ENC_KEY=" + utils.Config.Realtime.EncryptionKey,
"API_JWT_SECRET=" + utils.Config.Auth.JwtSecret,
fmt.Sprintf("API_JWT_JWKS=%s", jwks),
"METRICS_JWT_SECRET=" + utils.Config.Auth.JwtSecret,
"APP_NAME=realtime",
"SECRET_KEY_BASE=" + utils.Config.Realtime.SecretKeyBase,
Expand Down Expand Up @@ -787,7 +793,7 @@ EOF
"PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","),
fmt.Sprintf("PGRST_DB_MAX_ROWS=%d", utils.Config.Api.MaxRows),
"PGRST_DB_ANON_ROLE=anon",
"PGRST_JWT_SECRET=" + utils.Config.Auth.JwtSecret,
fmt.Sprintf("PGRST_JWT_SECRET=%s", jwks),
"PGRST_ADMIN_SERVER_PORT=3001",
},
// PostgREST does not expose a shell for health check
Expand Down Expand Up @@ -820,6 +826,7 @@ EOF
"ANON_KEY=" + utils.Config.Auth.AnonKey,
"SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey,
"AUTH_JWT_SECRET=" + utils.Config.Auth.JwtSecret,
fmt.Sprintf("AUTH_JWT_JWKS=%s", jwks),
fmt.Sprintf("DATABASE_URL=postgresql://supabase_storage_admin:%s@%s:%d/%s", dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database),
fmt.Sprintf("FILE_SIZE_LIMIT=%v", utils.Config.Storage.FileSizeLimit),
"STORAGE_BACKEND=file",
Expand Down
219 changes: 219 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package config

import (
"bytes"
"context"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
Expand All @@ -23,6 +27,8 @@ import (
"github.com/joho/godotenv"
"github.com/spf13/viper"
"golang.org/x/mod/semver"

"github.com/supabase/cli/pkg/fetcher"
)

// Type for turning human-friendly bytes string ("5MB", "32kB") into an int64 during toml decoding.
Expand Down Expand Up @@ -250,6 +256,34 @@ type (
JwtSecret string `toml:"-" mapstructure:"jwt_secret"`
AnonKey string `toml:"-" mapstructure:"anon_key"`
ServiceRoleKey string `toml:"-" mapstructure:"service_role_key"`

ThirdParty thirdParty `toml:"third_party"`
}

thirdParty struct {
Firebase tpaFirebase `toml:"firebase"`
Auth0 tpaAuth0 `toml:"auth0"`
Cognito tpaCognito `toml:"aws_cognito"`
}

tpaFirebase struct {
Enabled bool `toml:"enabled"`

ProjectID string `toml:"project_id"`
}

tpaAuth0 struct {
Enabled bool `toml:"enabled"`

Tenant string `toml:"tenant"`
TenantRegion string `toml:"tenant_region"`
}

tpaCognito struct {
Enabled bool `toml:"enabled"`

UserPoolID string `toml:"user_pool_id"`
UserPoolRegion string `toml:"user_pool_region"`
}

email struct {
Expand Down Expand Up @@ -855,6 +889,10 @@ func (c *config) Validate() error {
c.Auth.External[ext] = provider
}
}
// Validate Third-Party Auth config
if err := c.Auth.ThirdParty.validate(); err != nil {
return err
}
// Validate functions config
if c.EdgeRuntime.Enabled {
allowed := []RequestPolicy{PolicyPerWorker, PolicyOneshot}
Expand Down Expand Up @@ -996,3 +1034,184 @@ func ValidateBucketName(name string) error {
}
return nil
}

func (f *tpaFirebase) issuerURL() string {
return fmt.Sprintf("https://securetoken.google.com/%s", f.ProjectID)
}

func (f *tpaFirebase) validate() error {
if f.ProjectID == "" {
return errors.New("Invalid config: auth.third_party.firebase is enabled but without a project_id.")
}

return nil
}

func (a *tpaAuth0) issuerURL() string {
if a.TenantRegion != "" {
return fmt.Sprintf("https://%s.%s.auth0.com", a.Tenant, a.TenantRegion)
}

return fmt.Sprintf("https://%s.auth0.com", a.Tenant)
}

func (a *tpaAuth0) validate() error {
if a.Tenant == "" {
return errors.New("Invalid config: auth.third_party.auth0 is enabled but without a tenant.")
}

return nil
}

func (c *tpaCognito) issuerURL() string {
return fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", c.UserPoolRegion, c.UserPoolID)
}

func (c *tpaCognito) validate() error {
if c.UserPoolID == "" {
return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.")
}

if c.UserPoolRegion == "" {
return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.")
}

return nil
}

func (tpa *thirdParty) validate() error {
enabled := 0

if tpa.Firebase.Enabled {
enabled += 1

if err := tpa.Firebase.validate(); err != nil {
return err
}
}

if tpa.Auth0.Enabled {
enabled += 1

if err := tpa.Auth0.validate(); err != nil {
return err
}
}

if tpa.Cognito.Enabled {
enabled += 1

if err := tpa.Cognito.validate(); err != nil {
return err
}
}

if enabled > 1 {
return errors.New("Invalid config: Only one third_party provider allowed to be enabled at a time.")
}

return nil
}

func (tpa *thirdParty) IssuerURL() string {
if tpa.Firebase.Enabled {
return tpa.Firebase.issuerURL()
}

if tpa.Auth0.Enabled {
return tpa.Auth0.issuerURL()
}

if tpa.Cognito.Enabled {
return tpa.Cognito.issuerURL()
}

return ""
}

// ResolveJWKS creates the JWKS from the JWT secret and Third-Party Auth
// configs by resolving the JWKS via the OIDC discovery URL.
// It always returns a JWKS string, except when there's an error fetching.
func (a *auth) ResolveJWKS(ctx context.Context) (string, error) {
var jwks struct {
Keys []json.RawMessage `json:"keys"`
}

issuerURL := a.ThirdParty.IssuerURL()
if issuerURL != "" {
discoveryURL := issuerURL + "/.well-known/openid-configuration"

t := &http.Client{Timeout: 10 * time.Second}
client := fetcher.NewFetcher(
issuerURL,
fetcher.WithHTTPClient(t),
fetcher.WithExpectedStatus(http.StatusOK),
)

resp, err := client.Send(ctx, http.MethodGet, "", nil)
if err != nil {
return "", err
}

type oidcConfiguration struct {
JWKSURI string `json:"jwks_uri"`
}

oidcConfig, err := fetcher.ParseJSON[oidcConfiguration](resp.Body)
if err != nil {
return "", err
}

if oidcConfig.JWKSURI == "" {
return "", fmt.Errorf("auth.third_party: OIDC configuration at URL %q does not expose a jwks_uri property", discoveryURL)
}

client = fetcher.NewFetcher(
oidcConfig.JWKSURI,
fetcher.WithHTTPClient(t),
fetcher.WithExpectedStatus(http.StatusOK),
)

resp, err = client.Send(ctx, http.MethodGet, "", nil)
if err != nil {
return "", err
}

type remoteJWKS struct {
Keys []json.RawMessage `json:"keys"`
}

rJWKS, err := fetcher.ParseJSON[remoteJWKS](resp.Body)
if err != nil {
return "", err
}

if len(rJWKS.Keys) == 0 {
return "", fmt.Errorf("auth.third_party: JWKS at URL %q as discovered from %q does not contain any JWK keys", oidcConfig.JWKSURI, discoveryURL)
}

jwks.Keys = rJWKS.Keys
}

var secretJWK struct {
KeyType string `json:"kty"`
KeyBase64URL string `json:"k"`
}

secretJWK.KeyType = "oct"
secretJWK.KeyBase64URL = base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret))

secretJWKEncoded, err := json.Marshal(&secretJWK)
if err != nil {
return "", errors.Errorf("failed to marshal secret jwk: %w", err)
}

jwks.Keys = append(jwks.Keys, json.RawMessage(secretJWKEncoded))

jwksEncoded, err := json.Marshal(jwks)
if err != nil {
return "", errors.Errorf("failed to marshal jwks keys: %w", err)
}

return string(jwksEncoded), nil
}
17 changes: 17 additions & 0 deletions pkg/config/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,23 @@ url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false

# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"

# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"

# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"

[edge_runtime]
enabled = true
# Configure one of the supported request policies: `oneshot`, `per_worker`.
Expand Down

0 comments on commit 21a0a58

Please sign in to comment.