diff --git a/cmd/functions.go b/cmd/functions.go index bbf99bb9a8..50aa1f2bab 100644 --- a/cmd/functions.go +++ b/cmd/functions.go @@ -85,6 +85,14 @@ var ( }, } + authMode = utils.EnumFlag{ + Allowed: []string{ + string(new_.AuthModeNone), + string(new_.AuthModeApiKey), + string(new_.AuthModeUser), + }, + Value: string(new_.AuthModeApiKey), + } functionsNewCmd = &cobra.Command{ Use: "new ", Short: "Create a new Function locally", @@ -94,7 +102,9 @@ var ( return cmd.Root().PersistentPreRunE(cmd, args) }, RunE: func(cmd *cobra.Command, args []string) error { - return new_.Run(cmd.Context(), args[0], afero.NewOsFs()) + authMode := new_.AuthMode(authMode.Value) + + return new_.Run(cmd.Context(), args[0], authMode, afero.NewOsFs()) }, } @@ -172,6 +182,7 @@ func init() { functionsDownloadCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle") cobra.CheckErr(downloadFlags.MarkHidden("legacy-bundle")) cobra.CheckErr(downloadFlags.MarkHidden("use-docker")) + functionsNewCmd.Flags().Var(&authMode, "auth", "use a specific auth mode") functionsCmd.AddCommand(functionsListCmd) functionsCmd.AddCommand(functionsDeleteCmd) functionsCmd.AddCommand(functionsDeployCmd) diff --git a/internal/functions/new/new.go b/internal/functions/new/new.go index 1b0f307912..46e13d4cb0 100644 --- a/internal/functions/new/new.go +++ b/internal/functions/new/new.go @@ -16,9 +16,22 @@ import ( "github.com/supabase/cli/internal/utils/flags" ) +type AuthMode string + +const ( + AuthModeNone AuthMode = "none" + AuthModeApiKey AuthMode = "apikey" + AuthModeUser AuthMode = "user" +) + var ( - //go:embed templates/index.ts - indexEmbed string + //go:embed templates/index_auth_mode_none.ts + indexAuthModeNoneEmbed string + //go:embed templates/index_auth_mode_apikey.ts + indexAuthModeApiKeyEmbed string + //go:embed templates/index_auth_mode_user.ts + indexAuthModeUserEmbed string + //go:embed templates/deno.json denoEmbed string //go:embed templates/.npmrc @@ -26,16 +39,26 @@ var ( //go:embed templates/config.toml configEmbed string - indexTemplate = template.Must(template.New("index").Parse(indexEmbed)) + indexAuthTemplates = map[AuthMode]*template.Template{ + AuthModeNone: template.Must(template.New("index").Parse(indexAuthModeNoneEmbed)), + AuthModeApiKey: template.Must(template.New("index").Parse(indexAuthModeApiKeyEmbed)), + AuthModeUser: template.Must(template.New("index").Parse(indexAuthModeUserEmbed)), + } + configTemplate = template.Must(template.New("config").Parse(configEmbed)) ) type indexConfig struct { - URL string - Token string + URL string + PublishableKey string +} + +type functionConfig struct { + Slug string + VerifyJWT bool } -func Run(ctx context.Context, slug string, fsys afero.Fs) error { +func Run(ctx context.Context, slug string, authMode AuthMode, fsys afero.Fs) error { // 1. Sanity checks. if err := utils.ValidateFunctionSlug(slug); err != nil { return err @@ -56,17 +79,18 @@ func Run(ctx context.Context, slug string, fsys afero.Fs) error { if err := flags.LoadConfig(fsys); err != nil { fmt.Fprintln(utils.GetDebugLogger(), err) } - if err := createEntrypointFile(slug, fsys); err != nil { + if err := createEntrypointFile(slug, authMode, fsys); err != nil { return err } - if err := appendConfigFile(slug, fsys); err != nil { + verifyJWT := authMode == AuthModeUser + if err := appendConfigFile(slug, verifyJWT, fsys); err != nil { return err } // 3. Create optional files - if err := afero.WriteFile(fsys, filepath.Join(funcDir, "deno.json"), []byte(denoEmbed), 0644); err != nil { + if err := afero.WriteFile(fsys, filepath.Join(funcDir, "deno.json"), []byte(denoEmbed), 0o644); err != nil { return errors.Errorf("failed to create deno.json config: %w", err) } - if err := afero.WriteFile(fsys, filepath.Join(funcDir, ".npmrc"), []byte(npmrcEmbed), 0644); err != nil { + if err := afero.WriteFile(fsys, filepath.Join(funcDir, ".npmrc"), []byte(npmrcEmbed), 0o644); err != nil { return errors.Errorf("failed to create .npmrc config: %w", err) } fmt.Println("Created new Function at " + utils.Bold(funcDir)) @@ -79,33 +103,40 @@ func Run(ctx context.Context, slug string, fsys afero.Fs) error { return nil } -func createEntrypointFile(slug string, fsys afero.Fs) error { +func createEntrypointFile(slug string, authMode AuthMode, fsys afero.Fs) error { entrypointPath := filepath.Join(utils.FunctionsDir, slug, "index.ts") - f, err := fsys.OpenFile(entrypointPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + f, err := fsys.OpenFile(entrypointPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return errors.Errorf("failed to create entrypoint: %w", err) } defer f.Close() + indexTemplate, hasTemplate := indexAuthTemplates[authMode] + if !hasTemplate { + return errors.Errorf("failed to write entrypoint: '%v' is not a valid template", authMode) + } if err := indexTemplate.Option("missingkey=error").Execute(f, indexConfig{ - URL: utils.GetApiUrl("/functions/v1/" + slug), - Token: utils.Config.Auth.AnonKey.Value, + URL: utils.GetApiUrl("/functions/v1/" + slug), + PublishableKey: utils.Config.Auth.PublishableKey.Value, }); err != nil { return errors.Errorf("failed to write entrypoint: %w", err) } return nil } -func appendConfigFile(slug string, fsys afero.Fs) error { +func appendConfigFile(slug string, verifyJWT bool, fsys afero.Fs) error { if _, exists := utils.Config.Functions[slug]; exists { fmt.Fprintf(os.Stderr, "[functions.%s] is already declared in %s\n", slug, utils.Bold(utils.ConfigPath)) return nil } - f, err := fsys.OpenFile(utils.ConfigPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + f, err := fsys.OpenFile(utils.ConfigPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) if err != nil { return errors.Errorf("failed to append config: %w", err) } defer f.Close() - if err := configTemplate.Option("missingkey=error").Execute(f, slug); err != nil { + if err := configTemplate.Option("missingkey=error").Execute(f, functionConfig{ + Slug: slug, + VerifyJWT: verifyJWT, + }); err != nil { return errors.Errorf("failed to append template: %w", err) } return nil diff --git a/internal/functions/new/new_test.go b/internal/functions/new/new_test.go index df082d0d05..e86a691181 100644 --- a/internal/functions/new/new_test.go +++ b/internal/functions/new/new_test.go @@ -2,6 +2,7 @@ package new import ( "context" + "fmt" "path/filepath" "testing" @@ -16,7 +17,7 @@ func TestNewCommand(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() // Run test - assert.NoError(t, Run(context.Background(), "test-func", fsys)) + assert.NoError(t, Run(context.Background(), "test-func", AuthModeNone, fsys)) // Validate output funcPath := filepath.Join(utils.FunctionsDir, "test-func", "index.ts") content, err := afero.ReadFile(fsys, funcPath) @@ -26,8 +27,11 @@ func TestNewCommand(t *testing.T) { ) // Verify config.toml is updated - _, err = afero.ReadFile(fsys, utils.ConfigPath) + content, err = afero.ReadFile(fsys, utils.ConfigPath) assert.NoError(t, err, "config.toml should be created") + assert.Contains(t, string(content), "[functions.test-func]") + // Always access mode should not verify jwt + assert.Contains(t, string(content), "verify_jwt = false") // Verify deno.json exists denoPath := filepath.Join(utils.FunctionsDir, "test-func", "deno.json") @@ -40,23 +44,54 @@ func TestNewCommand(t *testing.T) { assert.NoError(t, err, ".npmrc should be created") }) + t.Run("creates new function with apikey access", func(t *testing.T) { + fsys := afero.NewMemMapFs() + assert.NoError(t, Run(context.Background(), "test-func", AuthModeApiKey, fsys)) + + // Validate output + funcPath := filepath.Join(utils.FunctionsDir, "test-func", "index.ts") + content, _ := afero.ReadFile(fsys, funcPath) + // Should contain the PublishableKey as example + assert.Contains(t, string(content), fmt.Sprintf("--header 'apiKey: %v'", utils.Config.Auth.PublishableKey.Value)) + + // Verify config.toml is updated to not verify jwt + content, _ = afero.ReadFile(fsys, utils.ConfigPath) + assert.Contains(t, string(content), "verify_jwt = false") + }) + + t.Run("creates new function with user access", func(t *testing.T) { + fsys := afero.NewMemMapFs() + assert.NoError(t, Run(context.Background(), "test-func", AuthModeUser, fsys)) + + // Validate output + funcPath := filepath.Join(utils.FunctionsDir, "test-func", "index.ts") + content, _ := afero.ReadFile(fsys, funcPath) + // Should contain the PublishableKey as example as well placeholder for UserToken + assert.Contains(t, string(content), fmt.Sprintf("--header 'apiKey: %v'", utils.Config.Auth.PublishableKey.Value)) + assert.Contains(t, string(content), "--header 'Authorization: Bearer '") + + // Verify config.toml is updated and verify jwt enabled + content, _ = afero.ReadFile(fsys, utils.ConfigPath) + assert.Contains(t, string(content), "verify_jwt = true") + }) + t.Run("throws error on malformed slug", func(t *testing.T) { - assert.Error(t, Run(context.Background(), "@", afero.NewMemMapFs())) + assert.Error(t, Run(context.Background(), "@", AuthModeNone, afero.NewMemMapFs())) }) t.Run("throws error on duplicate slug", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() funcPath := filepath.Join(utils.FunctionsDir, "test-func", "index.ts") - require.NoError(t, afero.WriteFile(fsys, funcPath, []byte{}, 0644)) + require.NoError(t, afero.WriteFile(fsys, funcPath, []byte{}, 0o644)) // Run test - assert.Error(t, Run(context.Background(), "test-func", fsys)) + assert.Error(t, Run(context.Background(), "test-func", AuthModeNone, fsys)) }) t.Run("throws error on permission denied", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) // Run test - assert.Error(t, Run(context.Background(), "test-func", fsys)) + assert.Error(t, Run(context.Background(), "test-func", AuthModeNone, fsys)) }) } diff --git a/internal/functions/new/templates/config.toml b/internal/functions/new/templates/config.toml index 9b3b197098..30f63a70ee 100644 --- a/internal/functions/new/templates/config.toml +++ b/internal/functions/new/templates/config.toml @@ -1,11 +1,11 @@ -[functions.{{ . }}] +[functions.{{ .Slug }}] enabled = true -verify_jwt = true -import_map = "./functions/{{ . }}/deno.json" +verify_jwt = {{ .VerifyJWT }} +import_map = "./functions/{{ .Slug }}/deno.json" # Uncomment to specify a custom file path to the entrypoint. # Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx -entrypoint = "./functions/{{ . }}/index.ts" +entrypoint = "./functions/{{ .Slug }}/index.ts" # Specifies static files to be bundled with the function. Supports glob patterns. # For example, if you want to serve static HTML pages in your function: -# static_files = [ "./functions/{{ . }}/*.html" ] +# static_files = [ "./functions/{{ .Slug }}/*.html" ] diff --git a/internal/functions/new/templates/deno.json b/internal/functions/new/templates/deno.json index 758d0703d1..db206e8f46 100644 --- a/internal/functions/new/templates/deno.json +++ b/internal/functions/new/templates/deno.json @@ -1,5 +1,6 @@ { "imports": { - "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + "@supabase/functions-js": "jsr:@supabase/functions-js@^2", + "@supabase/server": "npm:@supabase/server@^1" } } diff --git a/internal/functions/new/templates/index_auth_mode_apikey.ts b/internal/functions/new/templates/index_auth_mode_apikey.ts new file mode 100644 index 0000000000..4f9aa1f430 --- /dev/null +++ b/internal/functions/new/templates/index_auth_mode_apikey.ts @@ -0,0 +1,46 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts"; +import { withSupabase } from "@supabase/server"; + +console.log("Hello from Functions!"); + +// This endpoint uses 'publishable' | 'secret' access, apiKey is required. +// Use publishable for Client-facing, key-validated endpoints +// Use secret for Server-to-server, internal calls +export default { + fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => { + // Called by another service with a secret key + // ctx.supabaseAdmin bypasses RLS — use for privileged operations + /* + if (ctx.authType === "secret") { + const { user_id } = await req.json(); + const { data } = await ctx.supabaseAdmin.auth.admin.getUserById(user_id); + + return Response.json({ + email: data?.user?.email, + }); + } + */ + + const { name } = await req.json(); + + return Response.json({ + message: `Hello ${name}!`, + }); + }), +}; + +/* To invoke locally: + + 1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start) + 2. Make an HTTP request: + + curl -i --location --request POST '{{ .URL }}' \ + --header 'apiKey: {{ .PublishableKey }}' \ + --data '{"name":"Functions"}' + +*/ diff --git a/internal/functions/new/templates/index_auth_mode_none.ts b/internal/functions/new/templates/index_auth_mode_none.ts new file mode 100644 index 0000000000..a9c8e6e93e --- /dev/null +++ b/internal/functions/new/templates/index_auth_mode_none.ts @@ -0,0 +1,32 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts"; +import { withSupabase } from "@supabase/server"; + +console.log("Hello from Functions!"); + +// This endpoint uses auth 'none', no credentials required, every request is accepted. +// Use it for health checks, public APIs, or when you need to implement your own auth logic. +export default { + fetch: withSupabase({ auth: "none" }, async (req, ctx) => { + const { name } = await req.json(); + + return Response.json({ + message: `Hello ${name}!`, + }); + }), +}; + +/* To invoke locally: + + 1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start) + 2. Make an HTTP request: + + curl -i --location --request POST '{{ .URL }}' \ + --header 'Content-Type: application/json' \ + --data '{"name":"Functions"}' + +*/ diff --git a/internal/functions/new/templates/index.ts b/internal/functions/new/templates/index_auth_mode_user.ts similarity index 57% rename from internal/functions/new/templates/index.ts rename to internal/functions/new/templates/index_auth_mode_user.ts index dd1f7ce676..2851fe3b8d 100644 --- a/internal/functions/new/templates/index.ts +++ b/internal/functions/new/templates/index_auth_mode_user.ts @@ -4,20 +4,20 @@ // Setup type definitions for built-in Supabase Runtime APIs import "@supabase/functions-js/edge-runtime.d.ts" +import { withSupabase } from "@supabase/server" console.log("Hello from Functions!") -Deno.serve(async (req) => { - const { name } = await req.json() - const data = { - message: `Hello ${name}!`, - } +// This endpoint uses 'user' access, credentials is required. +export default { + fetch: withSupabase({ auth: "user" }, async (_req, ctx) => { + const email = ctx.userClaims?.email; - return new Response( - JSON.stringify(data), - { headers: { "Content-Type": "application/json" } }, - ) -}) + return Response.json({ + message: `Hello ${email}!`, + }) + }), +} /* To invoke locally: @@ -25,8 +25,6 @@ Deno.serve(async (req) => { 2. Make an HTTP request: curl -i --location --request POST '{{ .URL }}' \ - --header 'Authorization: Bearer {{ .Token }}' \ - --header 'Content-Type: application/json' \ - --data '{"name":"Functions"}' - + --header 'apiKey: {{ .PublishableKey }}' \ + --header 'Authorization: Bearer ' */ diff --git a/internal/start/start.go b/internal/start/start.go index dd195cfb66..276b277e7f 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -1283,6 +1283,7 @@ func buildGotrueEnv(dbConfig pgconn.Config) []string { if len(utils.Config.Auth.Sms.TestOTP) > 0 { formatMapForEnvConfig(utils.Config.Auth.Sms.TestOTP, &testOTP) } + mailerVerifyURL := strings.TrimRight(utils.Config.AuthExternalURL(), "/") + "/verify" return []string{ "API_EXTERNAL_URL=" + utils.Config.AuthExternalURL(), @@ -1315,10 +1316,10 @@ func buildGotrueEnv(dbConfig pgconn.Config) []string { fmt.Sprintf("GOTRUE_SMTP_MAX_FREQUENCY=%v", utils.Config.Auth.Email.MaxFrequency), - "GOTRUE_MAILER_URLPATHS_INVITE=/verify", - "GOTRUE_MAILER_URLPATHS_CONFIRMATION=/verify", - "GOTRUE_MAILER_URLPATHS_RECOVERY=/verify", - "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/verify", + "GOTRUE_MAILER_URLPATHS_INVITE=" + mailerVerifyURL, + "GOTRUE_MAILER_URLPATHS_CONFIRMATION=" + mailerVerifyURL, + "GOTRUE_MAILER_URLPATHS_RECOVERY=" + mailerVerifyURL, + "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=" + mailerVerifyURL, "GOTRUE_RATE_LIMIT_EMAIL_SENT=360000", fmt.Sprintf("GOTRUE_EXTERNAL_PHONE_ENABLED=%v", utils.Config.Auth.Sms.EnableSignup), diff --git a/internal/start/start_test.go b/internal/start/start_test.go index 573c28b092..732ae64138 100644 --- a/internal/start/start_test.go +++ b/internal/start/start_test.go @@ -307,7 +307,7 @@ func TestBuildGotrueEnv(t *testing.T) { utils.Config = original }) - t.Run("uses auth scoped external url and relative mailer paths", func(t *testing.T) { + t.Run("uses auth scoped external url and absolute mailer verify urls", func(t *testing.T) { utils.Config = config.NewConfig() utils.Config.Api.ExternalUrl = "http://127.0.0.1:54321" utils.Config.Auth.ExternalUrl = "http://127.0.0.1:54321/auth/v1" @@ -328,10 +328,10 @@ func TestBuildGotrueEnv(t *testing.T) { assert.Equal(t, "http://127.0.0.1:54321/auth/v1", env["API_EXTERNAL_URL"]) assert.Equal(t, "http://127.0.0.1:54321/auth/v1", env["GOTRUE_JWT_ISSUER"]) - assert.Equal(t, "/verify", env["GOTRUE_MAILER_URLPATHS_INVITE"]) - assert.Equal(t, "/verify", env["GOTRUE_MAILER_URLPATHS_CONFIRMATION"]) - assert.Equal(t, "/verify", env["GOTRUE_MAILER_URLPATHS_RECOVERY"]) - assert.Equal(t, "/verify", env["GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE"]) + assert.Equal(t, "http://127.0.0.1:54321/auth/v1/verify", env["GOTRUE_MAILER_URLPATHS_INVITE"]) + assert.Equal(t, "http://127.0.0.1:54321/auth/v1/verify", env["GOTRUE_MAILER_URLPATHS_CONFIRMATION"]) + assert.Equal(t, "http://127.0.0.1:54321/auth/v1/verify", env["GOTRUE_MAILER_URLPATHS_RECOVERY"]) + assert.Equal(t, "http://127.0.0.1:54321/auth/v1/verify", env["GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE"]) assert.NotContains(t, env, "GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI") }) @@ -350,6 +350,7 @@ func TestBuildGotrueEnv(t *testing.T) { assert.Equal(t, "http://127.0.0.1:54321/auth/v1", env["API_EXTERNAL_URL"]) assert.Equal(t, "https://issuer.example.com/auth/v1", env["GOTRUE_JWT_ISSUER"]) + assert.Equal(t, "http://127.0.0.1:54321/auth/v1/verify", env["GOTRUE_MAILER_URLPATHS_INVITE"]) assert.Equal(t, "https://example.com/custom/callback", env["GOTRUE_EXTERNAL_AZURE_REDIRECT_URI"]) }) } diff --git a/pkg/api/types.gen.go b/pkg/api/types.gen.go index 91b2727ae9..f3ee2d4c09 100644 --- a/pkg/api/types.gen.go +++ b/pkg/api/types.gen.go @@ -1392,6 +1392,7 @@ const ( V1ListEntitlementsResponseEntitlementsFeatureKeyAssistantAdvanceModel V1ListEntitlementsResponseEntitlementsFeatureKey = "assistant.advance_model" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthAdvancedAuthSettings V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.advanced_auth_settings" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthCustomJwtTemplate V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.custom_jwt_template" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthCustomOauthMaxProviders V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.custom_oauth.max_providers" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthHooks V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.hooks" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthLeakedPasswordProtection V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.leaked_password_protection" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthMfaEnhancedSecurity V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.mfa_enhanced_security"