Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f863f9f
refactor(auth): introduce pluggable Provider chain (PR1 foundation)
May 22, 2026
39f4a67
feat(auth): add OIDC provider with JWKS + claim mapping (PR2)
May 22, 2026
3f65871
feat(auth): wire forge.yaml auth: block + egress allowlist (PR3)
May 22, 2026
381af95
chore(auth/oidc): drop unused removeKey test helper (lint fix)
May 22, 2026
8e7747f
feat(auth/audit): structured auth_verify + auth_fail events (PR4)
May 22, 2026
a894f01
feat(init/wizard): TUI + non-interactive auth: step (PR5)
May 23, 2026
fd55b14
feat(ui): web wizard Authentication step (PR6)
May 23, 2026
379d578
fix(auth/oidc): TTL-driven JWKS cache refresh (review #1)
May 23, 2026
3a7a131
fix(auth/oidc): normalize issuer trailing slash everywhere (review #2)
May 23, 2026
19f7251
fix(auth): panic on nil Chain unless AllowAnonymous=true (review #3)
May 23, 2026
b33522d
fix(auth): cross-check --no-auth against forge.yaml auth block (revie…
May 23, 2026
d8f1f9f
test(auth/oidc): prove no-hammering on JWKS refresh failure (review #5)
May 23, 2026
093079e
fix(auth): ErrProviderUnavailable for verifier-down scenarios (review…
May 23, 2026
cf4ae14
docs(auth/security): document port-stripping contract (review #7)
May 23, 2026
f30df7c
refactor(ui/static): drop misleading static/dist/ directory (review #8)
May 23, 2026
79491c1
fix(ui): server-side ValidateAuthConfig in handleCreateAgent (review #9)
May 23, 2026
51760a8
docs(auth): tighten loopback-prepend invariant + pin test (review #10)
May 23, 2026
b3d4264
fix(auth): six smaller-bug fixes from review (review #11)
May 23, 2026
08fa11b
chore(auth/statictoken): gofmt provider_test.go
May 23, 2026
49dd97a
test(auth): close coverage gaps + YAML quoting hardening (review #12)
May 23, 2026
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
56 changes: 53 additions & 3 deletions forge-cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ type initOptions struct {
Force bool // overwrite existing directory
CustomModel string // custom provider model name
AuthMethod string // "apikey" or "oauth"

// A2A auth chain (from the wizard's Authentication step or CLI flags).
AuthMode string // "", "none", "oidc", "http_verifier", "custom"
AuthSettings map[string]any // provider-specific settings block
AuthEgressHosts []string // hosts to merge into egress allowlist
}

// toolEntry represents a tool parsed from a skills file.
Expand All @@ -71,6 +76,12 @@ type templateData struct {
EgressDomains []string
EnvVars []envVarEntry
HasSecrets bool

// Auth chain rendering (see forge.yaml.tmpl). Pre-rendered as a YAML
// fragment because nested maps in the settings block (e.g. claim_map)
// would otherwise require non-trivial template helpers. Empty when
// the user picked "none" or skipped the auth step.
AuthBlock string
}

// fallbackTmplData holds template data for a fallback provider.
Expand Down Expand Up @@ -121,6 +132,15 @@ func init() {
initCmd.Flags().String("org-id", "", "OpenAI organization ID (enterprise)")
initCmd.Flags().StringSlice("fallbacks", nil, "fallback LLM providers (e.g., openai,gemini)")
initCmd.Flags().Bool("force", false, "overwrite existing directory")

// Auth chain (PR5+). All optional. When --auth is unset or "none", no
// auth: block is written and the agent runs anonymously.
initCmd.Flags().String("auth", "", "auth mode: none, oidc, http_verifier, custom")
initCmd.Flags().String("auth-issuer", "", "OIDC issuer URL (required with --auth=oidc)")
initCmd.Flags().String("auth-audience", "", "OIDC audience (required with --auth=oidc)")
initCmd.Flags().String("auth-url", "", "verifier URL (required with --auth=http_verifier)")
initCmd.Flags().String("auth-default-org", "", "default org_id for http_verifier (optional)")
initCmd.Flags().String("auth-groups-claim", "", "claim name for groups (oidc, default: groups)")
}

func runInit(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -155,6 +175,18 @@ func runInit(cmd *cobra.Command, args []string) error {
opts.NonInteractive = nonInteractive
opts.Force, _ = cmd.Flags().GetBool("force")

// Auth chain flags.
authMode, _ := cmd.Flags().GetString("auth")
if authMode != "" {
settings, hosts, err := buildAuthFromFlags(cmd, authMode)
if err != nil {
return err
}
opts.AuthMode = authMode
opts.AuthSettings = settings
opts.AuthEgressHosts = hosts
}

// TTY detection: require a terminal for interactive mode
if !nonInteractive && !term.IsTerminal(int(os.Stdout.Fd())) {
return fmt.Errorf("interactive mode requires a terminal; use --non-interactive")
Expand Down Expand Up @@ -258,6 +290,7 @@ func collectInteractive(opts *initOptions) error {
steps.NewToolsStep(styles, toolInfos, validateWebSearchKeyFn),
steps.NewSkillsStep(styles, skillInfos),
steps.NewEgressStep(styles, deriveEgressFn),
steps.NewAuthStep(styles),
steps.NewReviewStep(styles), // scaffold is handled by the caller after collectInteractive returns
}

Expand Down Expand Up @@ -327,11 +360,18 @@ func collectInteractive(opts *initOptions) error {
opts.EnvVars["MODEL_API_KEY"] = ctx.CustomAPIKey
}

// Store egress domains
if len(ctx.EgressDomains) > 0 {
opts.EnvVars["__egress_domains"] = strings.Join(ctx.EgressDomains, ",")
// Store egress domains, merging in any auth-provider hosts so the
// agent can reach its IdP / verifier at runtime.
mergedEgress := mergeEgressDomains(ctx.EgressDomains, ctx.AuthEgressHosts)
if len(mergedEgress) > 0 {
opts.EnvVars["__egress_domains"] = strings.Join(mergedEgress, ",")
}

// Auth chain selection.
opts.AuthMode = ctx.AuthMode
opts.AuthSettings = ctx.AuthSettings
opts.AuthEgressHosts = ctx.AuthEgressHosts

// Check skill requirements
checkSkillRequirements(opts)

Expand Down Expand Up @@ -1002,6 +1042,16 @@ func buildTemplateData(opts *initOptions) templateData {
data.EgressDomains = strings.Split(stored, ",")
}

// Merge any auth-provider hosts (PR5 wizard / --auth flags) into the
// allowlist so the agent's runtime can reach its IdP/verifier.
if len(opts.AuthEgressHosts) > 0 {
data.EgressDomains = mergeEgressDomains(data.EgressDomains, opts.AuthEgressHosts)
}

// Auth chain rendering: pre-render the YAML fragment in Go so nested
// maps (claim_map) emit correctly.
data.AuthBlock = renderAuthBlock(opts.AuthMode, opts.AuthSettings)

// Build env vars
data.EnvVars = buildEnvVars(opts)

Expand Down
274 changes: 274 additions & 0 deletions forge-cli/cmd/init_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
package cmd

import (
"fmt"
"net/url"
"sort"
"strings"

"github.com/spf13/cobra"
)

// buildAuthFromFlags reads the --auth* flag set into a settings map +
// the egress hosts the runtime will need. Validates required-field
// combinations; rejects unknown modes with a clear error.
func buildAuthFromFlags(cmd *cobra.Command, mode string) (settings map[string]any, egressHosts []string, err error) {
switch mode {
case "none", "custom":
return nil, nil, nil
case "oidc":
issuer, _ := cmd.Flags().GetString("auth-issuer")
audience, _ := cmd.Flags().GetString("auth-audience")
groupsClaim, _ := cmd.Flags().GetString("auth-groups-claim")
if issuer == "" || audience == "" {
return nil, nil, fmt.Errorf("--auth=oidc requires --auth-issuer and --auth-audience")
}
issuer = strings.TrimRight(issuer, "/")
settings = map[string]any{
"issuer": issuer,
"audience": audience,
}
if groupsClaim != "" && groupsClaim != "groups" {
settings["claim_map"] = map[string]any{"groups": groupsClaim}
}
if host := hostFromURL(issuer); host != "" {
egressHosts = []string{host}
}
return settings, egressHosts, nil
case "http_verifier":
verifierURL, _ := cmd.Flags().GetString("auth-url")
defaultOrg, _ := cmd.Flags().GetString("auth-default-org")
if verifierURL == "" {
return nil, nil, fmt.Errorf("--auth=http_verifier requires --auth-url")
}
settings = map[string]any{"url": verifierURL}
if defaultOrg != "" {
settings["default_org"] = defaultOrg
}
if host := hostFromURL(verifierURL); host != "" {
egressHosts = []string{host}
}
return settings, egressHosts, nil
default:
return nil, nil, fmt.Errorf("unknown --auth value %q (supported: none, oidc, http_verifier, custom)", mode)
}
}

// authEgressHostsFromSettings extracts the outbound hosts implied by an
// auth provider's settings block. Used by the Web UI path (cmd/ui.go) to
// derive the same egress hosts the TUI wizard / --auth flags compute.
//
// Returns nil for modes that don't require outbound (none, static_token,
// custom) or for malformed/missing URLs.
func authEgressHostsFromSettings(mode string, settings map[string]any) []string {
if settings == nil {
return nil
}
var hosts []string
switch mode {
case "oidc":
if h := hostFromURL(asStringSetting(settings, "issuer")); h != "" {
hosts = append(hosts, h)
}
if h := hostFromURL(asStringSetting(settings, "jwks_url")); h != "" {
hosts = append(hosts, h)
}
case "http_verifier":
if h := hostFromURL(asStringSetting(settings, "url")); h != "" {
hosts = append(hosts, h)
}
}
return hosts
}

// asStringSetting reads a string-valued setting, returning "" for missing
// or non-string values.
func asStringSetting(m map[string]any, key string) string {
v, ok := m[key]
if !ok {
return ""
}
s, _ := v.(string)
return s
}

// hostFromURL extracts the bare host (no port) from a URL string. Returns
// "" if the URL is malformed — validation happens in the wizard /
// buildAuthFromFlags above.
func hostFromURL(raw string) string {
if raw == "" {
return ""
}
u, perr := url.Parse(raw)
if perr != nil || u.Host == "" {
return ""
}
return u.Hostname()
}

// renderAuthBlock returns a forge.yaml `auth:` fragment for the given
// provider type and settings. The output starts at column 0 with no
// trailing newline so the surrounding template controls spacing.
//
// Three shapes are produced:
//
// mode == "" → empty string (skip)
// mode == "none" → empty string (anonymous)
// mode == "custom"
// ↓
// # Auth provider chain — configure here.
// # auth:
// # required: true
// # providers: [...]
//
// mode == "oidc" | "http_verifier" | "static_token"
// ↓
// auth:
// required: true
// providers:
// - type: <mode>
// settings:
// key: value
// nested:
// k: v
//
// The AuthProvider.Name field is intentionally NOT emitted — the wizard
// doesn't capture one, and the previous signature took a `name` arg
// that callers always set equal to mode (suppressed). Removed in
// review #11d. If a future wizard step adds explicit name capture,
// reintroduce the parameter then.
//
// Settings keys are emitted in alphabetical order so the output is
// deterministic across runs (useful for diffing generated files).
func renderAuthBlock(mode string, settings map[string]any) string {
switch mode {
case "", "none":
return ""
case "custom":
return customAuthStub()
}

var b strings.Builder
b.WriteString("auth:\n")
b.WriteString(" required: true\n")
b.WriteString(" providers:\n")
fmt.Fprintf(&b, " - type: %s\n", mode)
if len(settings) > 0 {
b.WriteString(" settings:\n")
writeYAMLMap(&b, settings, " ")
}
// Trim the final newline so the template controls spacing.
return strings.TrimRight(b.String(), "\n")
}

// customAuthStub returns the commented-out template a user gets when they
// pick "Custom" in the wizard.
func customAuthStub() string {
return strings.Join([]string{
"# Auth provider chain — configure here. See useforge.ai/docs/auth",
"# Supported types: oidc, http_verifier, static_token",
"# auth:",
"# required: true",
"# providers:",
"# - type: oidc",
"# settings:",
"# issuer: https://login.example.com",
"# audience: api://forge",
}, "\n")
}

// writeYAMLMap renders a `map[string]any` as YAML lines, recursing into
// nested maps. Only string / number / bool / map values are supported —
// the auth-settings schema doesn't use anything else.
//
// String values are conservatively quoted (review #12.8) when they
// contain YAML-significant characters. Otherwise the unquoted form is
// emitted to keep generated forge.yaml readable.
func writeYAMLMap(b *strings.Builder, m map[string]any, indent string) {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := m[k]
switch val := v.(type) {
case map[string]any:
fmt.Fprintf(b, "%s%s:\n", indent, k)
writeYAMLMap(b, val, indent+" ")
case string:
fmt.Fprintf(b, "%s%s: %s\n", indent, k, yamlScalar(val))
default:
fmt.Fprintf(b, "%s%s: %v\n", indent, k, val)
}
}
}

// yamlScalar returns a YAML-safe rendering of a string value. Plain
// values pass through unchanged; values containing characters that
// would break the YAML parser (": " sequences, leading specials,
// reserved tokens, control chars) are emitted as double-quoted strings
// with the minimum necessary escaping.
//
// This is deliberately not a general YAML serializer — it covers the
// subset that appears in auth provider settings (issuer URLs, audiences,
// claim names, default org strings). If we ever need richer values,
// switch to gopkg.in/yaml.v3 for the whole block.
func yamlScalar(s string) string {
if !needsYAMLQuoting(s) {
return s
}
var out strings.Builder
out.WriteByte('"')
for _, r := range s {
switch r {
case '"':
out.WriteString(`\"`)
case '\\':
out.WriteString(`\\`)
case '\n':
out.WriteString(`\n`)
case '\r':
out.WriteString(`\r`)
case '\t':
out.WriteString(`\t`)
default:
out.WriteRune(r)
}
}
out.WriteByte('"')
return out.String()
}

// needsYAMLQuoting reports whether a string would change meaning when
// emitted unquoted in a YAML block-scalar context. Conservative —
// false positives are fine (extra quotes), false negatives are bugs
// (broken YAML).
func needsYAMLQuoting(s string) bool {
if s == "" {
return true
}
// Leading characters that begin YAML indicators (tags, anchors,
// aliases, folded/literal block markers, flow markers, directives,
// quotes, comments, leading whitespace).
switch s[0] {
case '!', '&', '*', '>', '|', '%', '@', '`', '"', '\'', '#', ' ', '\t', '[', ']', '{', '}', ',', '?', ':', '-':
return true
}
// Trailing colon (key-like form) or any unquoted ": " (mapping
// indicator inside a scalar would split into key/value).
if strings.HasSuffix(s, ":") || strings.Contains(s, ": ") || strings.Contains(s, " #") {
return true
}
// Control / newline characters.
if strings.ContainsAny(s, "\n\r\t\v\f") {
return true
}
// YAML 1.1 boolean / null literals — unquoted they decode to bool/nil.
// We keep this case-insensitive to match yaml.v3 defaults.
switch strings.ToLower(s) {
case "true", "false", "yes", "no", "on", "off", "null", "~":
return true
}
return false
}
Loading
Loading