feat(08-07): wire repeatable bonfire flags and export client-issuing helpers
- bonfire.wrap registers a Repeatable Flag as a Cobra StringSlice so Input.Flags returns every repeated --name=value occurrence in order; scalar/bare flags are unaffected (D-19) - wristband.IssueClientCredentials/RejectRedirectURI export the exact random-id/secret/hash and redirect-URI validation RFC 7591 registration already uses, so the fonoteka:oauth-client operator command shares one hash/validation path with DCR (T-08-SECRET-TIMING)
This commit is contained in:
@@ -300,6 +300,75 @@ func TestPhase8RedBonfireFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRepeatableFlagUnsetReturnsEmpty proves an unset Repeatable flag comes
|
||||||
|
// back as an empty/nil slice, not an error or a panic (D-19 GREEN
|
||||||
|
// companion to TestPhase8RedBonfireFlags).
|
||||||
|
func TestRepeatableFlagUnsetReturnsEmpty(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var got []string
|
||||||
|
root, err := NewRootIO("app", []Command{{
|
||||||
|
Name: "demo:unset",
|
||||||
|
Flags: []Flag{
|
||||||
|
{Name: "scope", Repeatable: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in Input, out Output) error {
|
||||||
|
got = in.Flags("scope")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}, strings.NewReader(""), &buf, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root.SetArgs([]string{"demo:unset"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("unset repeatable flag = %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepeatableFlagCoexistsWithBareAndScalar proves a Repeatable flag,
|
||||||
|
// a Bare flag and a scalar flag on the same command parse independently
|
||||||
|
// (D-19 acceptance criteria: existing scalar/bare callers stay unaffected).
|
||||||
|
func TestRepeatableFlagCoexistsWithBareAndScalar(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var gotRedirect []string
|
||||||
|
var gotList, gotListOK bool
|
||||||
|
var gotName string
|
||||||
|
root, err := NewRootIO("app", []Command{{
|
||||||
|
Name: "demo:mixed",
|
||||||
|
Flags: []Flag{
|
||||||
|
{Name: "redirect-uri", Repeatable: true},
|
||||||
|
{Name: "list", Bare: true},
|
||||||
|
{Name: "name", Default: ""},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in Input, out Output) error {
|
||||||
|
gotRedirect = in.Flags("redirect-uri")
|
||||||
|
listVal, ok := in.Flag("list")
|
||||||
|
gotList, gotListOK = listVal == "true", ok
|
||||||
|
gotName, _ = in.Flag("name")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}, strings.NewReader(""), &buf, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root.SetArgs([]string{"demo:mixed", "--redirect-uri=https://x.example", "--list", "--name=Catalog"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(gotRedirect, []string{"https://x.example"}) {
|
||||||
|
t.Fatalf("redirect-uri = %v", gotRedirect)
|
||||||
|
}
|
||||||
|
if !gotListOK || !gotList {
|
||||||
|
t.Fatalf("list = (%v, %v), want (true, true)", gotList, gotListOK)
|
||||||
|
}
|
||||||
|
if gotName != "Catalog" {
|
||||||
|
t.Fatalf("name = %q", gotName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHelpUsesSharedAdapter(t *testing.T) {
|
func TestHelpUsesSharedAdapter(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
root, err := NewRoot("hello", []Command{{
|
root, err := NewRoot("hello", []Command{{
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ func wrap(c Command, out Output) (*cobra.Command, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, flag := range c.Flags {
|
for _, flag := range c.Flags {
|
||||||
|
if flag.Repeatable {
|
||||||
|
// D-19: an ordered, multi-occurrence string flag (Cobra
|
||||||
|
// StringSlice), read back through Input.Flags. Default is a
|
||||||
|
// single-element slice only when non-empty, matching the
|
||||||
|
// scalar path's zero-value convention.
|
||||||
|
var def []string
|
||||||
|
if flag.Default != "" {
|
||||||
|
def = []string{flag.Default}
|
||||||
|
}
|
||||||
|
if flag.Shorthand != "" {
|
||||||
|
cmd.Flags().StringSliceP(flag.Name, flag.Shorthand, def, flag.Description)
|
||||||
|
} else {
|
||||||
|
cmd.Flags().StringSlice(flag.Name, def, flag.Description)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if flag.Shorthand != "" {
|
if flag.Shorthand != "" {
|
||||||
cmd.Flags().StringP(flag.Name, flag.Shorthand, flag.Default, flag.Description)
|
cmd.Flags().StringP(flag.Name, flag.Shorthand, flag.Default, flag.Description)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
40
wristband/client_issue.go
Normal file
40
wristband/client_issue.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Shared client-issuing primitives used by both RFC 7591 registration
|
||||||
|
// (register.go) and the app-owned fonoteka:oauth-client operator command
|
||||||
|
// (08-CONTEXT.md D-19; T-08-SECRET-TIMING: "Shared hash/validation path and
|
||||||
|
// one-time secret"). Exporting these rather than letting the command
|
||||||
|
// re-derive its own random-id/hash/redirect-URI-validation logic keeps
|
||||||
|
// exactly one code path responsible for how an OAuth client secret is
|
||||||
|
// generated and hashed.
|
||||||
|
package wristband
|
||||||
|
|
||||||
|
// IssueClientCredentials mints a random opaque client_id (16 raw bytes,
|
||||||
|
// base64url) and, for every token_endpoint_auth_method other than "none", a
|
||||||
|
// client_secret (32 raw bytes) plus its sha256 hex hash -- the identical
|
||||||
|
// fixed transform RFC 7591 registration uses (D-04). secret is "" and
|
||||||
|
// secretHash is nil for a public ("none") client. The raw secret is
|
||||||
|
// returned exactly once; only secretHash is meant to be persisted.
|
||||||
|
func IssueClientCredentials(authMethod string) (clientID, secret string, secretHash *string, err error) {
|
||||||
|
clientID, err = randomBase64URL(16)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
if authMethod == "none" {
|
||||||
|
return clientID, "", nil, nil
|
||||||
|
}
|
||||||
|
secret, err = randomBase64URL(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
h := sha256Hex(secret)
|
||||||
|
return clientID, secret, &h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectRedirectURI is the exported form of the redirect-URI validation
|
||||||
|
// RFC 7591 registration already enforces (PHP OAuthClient::rejectRedirectUri):
|
||||||
|
// at most 512 characters, a valid URL with scheme+host, https:// or loopback
|
||||||
|
// http://127.0.0.1 / http://localhost. It returns "" when uri is accepted,
|
||||||
|
// or a human-readable rejection reason otherwise. The operator command
|
||||||
|
// shares this exact rule with DCR rather than re-deriving it (D-19).
|
||||||
|
func RejectRedirectURI(uri string) string {
|
||||||
|
return rejectRedirectURI(uri)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user