Skip to content

Commit a4f860c

Browse files
committed
Add OAuth app registration commands
1 parent a0e1fc1 commit a4f860c

5 files changed

Lines changed: 263 additions & 1 deletion

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,39 @@ server process.
267267
OAuth provider setup and OpenAPI integration workflows have first-class wrappers. The generic `create`, `list`, `describe action`, and `execute` commands still work, but these commands keep the common lifecycle discoverable and avoid passing large specs as shell arguments.
268268

269269
```bash
270+
# Register a Daptin OAuth provider client app
271+
export APP_CALLBACK_URL=https://app.example.com/auth/daptin/callback
272+
daptin-cli oauth app register \
273+
--name "App Login" \
274+
--redirect-uri "$APP_CALLBACK_URL" \
275+
--scope openid \
276+
--scope profile \
277+
--scope email \
278+
--grant authorization_code \
279+
--grant refresh_token
280+
281+
# Inspect provider-side OAuth apps and rotate a confidential client secret
282+
daptin-cli oauth app list
283+
daptin-cli oauth app describe <client_id_or_reference_id>
284+
daptin-cli oauth app rotate-secret <client_id_or_reference_id>
285+
286+
# Use the returned client_id/client_secret to configure Daptin self-login
287+
export DAPTIN_BASE_URL=https://daptin.example.com
288+
export DAPTIN_SELF_CLIENT_SECRET=...
289+
daptin-cli oauth connect create daptin-login \
290+
--client-id <client_id> \
291+
--client-secret-env DAPTIN_SELF_CLIENT_SECRET \
292+
--auth-url "$DAPTIN_BASE_URL/oauth/authorize" \
293+
--token-url "$DAPTIN_BASE_URL/oauth/token" \
294+
--profile-url "$DAPTIN_BASE_URL/oauth/userinfo" \
295+
--scope openid,profile,email \
296+
--redirect-uri "$APP_CALLBACK_URL" \
297+
--profile-email-path email \
298+
--allow-login \
299+
--pkce \
300+
--access-type-offline \
301+
--update
302+
270303
# Create an OAuth connection without putting the client secret in shell history
271304
export ASANA_CLIENT_SECRET=...
272305
daptin-cli oauth connect create asana.com \

cmd/args.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ var commandSubcommands = map[string]map[string]bool{
2525
"upload": true, "download": true, "mv": true, "rm": true, "mkdir": true,
2626
},
2727
"asset": {"upload": true, "list": true},
28-
"oauth": {"connect": true, "login-url": true, "tokens": true},
28+
"oauth": {"app": true, "connect": true, "login-url": true, "tokens": true},
29+
"app": {"register": true, "list": true, "describe": true, "rotate-secret": true},
2930
"connect": {"create": true, "list": true},
3031
"tokens": {"list": true},
3132
"integration": {
@@ -80,11 +81,13 @@ var valueFlags = map[string]bool{
8081
"--credential-id": true,
8182
"--input-json": true,
8283
"--input-file": true,
84+
"--name": true,
8385
"--client-id": true,
8486
"--client-secret": true,
8587
"--client-secret-env": true,
8688
"--client-secret-file": true,
8789
"--scope": true,
90+
"--grant": true,
8891
"--response-type": true,
8992
"--redirect-uri": true,
9093
"--auth-url": true,
@@ -110,6 +113,8 @@ var boolFlags = map[string]bool{
110113
"--allow-login": true,
111114
"--access-type-offline": true,
112115
"--pkce": true,
116+
"--confidential": true,
117+
"--public": true,
113118
"--open": true,
114119
"--help": true, "-h": true,
115120
"--version": true, "-v": true,

cmd/args_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ func TestReorderArgs_OAuthNestedSubcommand(t *testing.T) {
135135
}
136136
}
137137

138+
func TestReorderArgs_OAuthAppRegister(t *testing.T) {
139+
input := []string{"daptin", "oauth", "app", "register", "--name", "App Login", "--redirect-uri", "https://app.example.com/auth/daptin/callback", "--grant", "refresh_token"}
140+
expected := []string{"daptin", "oauth", "app", "register", "--name", "App Login", "--redirect-uri", "https://app.example.com/auth/daptin/callback", "--grant", "refresh_token"}
141+
142+
result := ReorderArgs(input)
143+
if !reflect.DeepEqual(result, expected) {
144+
t.Errorf("expected %v, got %v", expected, result)
145+
}
146+
}
147+
138148
func TestReorderArgs_IntegrationExecute(t *testing.T) {
139149
input := []string{"daptin", "integration", "execute", "asana.com", "getWorkspaces", "--oauth-token-id", "tok", "workspace=abc"}
140150
expected := []string{"daptin", "integration", "execute", "--oauth-token-id", "tok", "asana.com", "getWorkspaces", "workspace=abc"}

cmd/oauth.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,123 @@ func oauthCommand(appCtx *AppContext) *cli.Command {
2424
daptin oauth tokens list --provider asana.com`,
2525
Description: "Wraps Daptin's oauth_connect and oauth_token tables without printing client secrets or tokens by default.",
2626
Subcommands: []*cli.Command{
27+
oauthAppCommand(appCtx),
2728
oauthConnectCommand(appCtx),
2829
oauthLoginURLCommand(appCtx),
2930
oauthTokensCommand(appCtx),
3031
},
3132
}
3233
}
3334

35+
func oauthAppCommand(appCtx *AppContext) *cli.Command {
36+
return &cli.Command{
37+
Name: "app",
38+
Usage: "Manage Daptin OAuth provider client apps",
39+
Subcommands: []*cli.Command{
40+
oauthAppRegisterCommand(appCtx),
41+
oauthAppListCommand(appCtx),
42+
oauthAppDescribeCommand(appCtx),
43+
oauthAppRotateSecretCommand(appCtx),
44+
},
45+
}
46+
}
47+
48+
func oauthAppRegisterCommand(appCtx *AppContext) *cli.Command {
49+
return &cli.Command{
50+
Name: "register",
51+
Usage: "Register an OAuth client app for Daptin's OAuth provider",
52+
ArgsUsage: "",
53+
UsageText: `daptin oauth app register --name "App Login" --redirect-uri https://app.example.com/auth/daptin/callback --scope openid --scope profile --scope email`,
54+
Flags: []cli.Flag{
55+
&cli.StringFlag{Name: "name", Usage: "OAuth app display name"},
56+
&cli.StringSliceFlag{Name: "redirect-uri", Usage: "Allowed redirect URI; repeat for multiple values"},
57+
&cli.StringSliceFlag{Name: "scope", Usage: "Allowed scope; repeat or pass comma-separated values"},
58+
&cli.StringSliceFlag{Name: "grant", Usage: "Allowed grant; repeat or pass comma-separated values"},
59+
&cli.BoolFlag{Name: "confidential", Usage: "Register a confidential client (default)"},
60+
&cli.BoolFlag{Name: "public", Usage: "Register a public client with no client secret"},
61+
},
62+
Action: func(c *cli.Context) error {
63+
name := strings.TrimSpace(c.String("name"))
64+
if name == "" {
65+
return fmt.Errorf("--name is required")
66+
}
67+
if c.Bool("confidential") && c.Bool("public") {
68+
return fmt.Errorf("--confidential and --public are mutually exclusive")
69+
}
70+
redirectURIs := c.StringSlice("redirect-uri")
71+
if len(redirectURIs) == 0 {
72+
return fmt.Errorf("--redirect-uri is required")
73+
}
74+
75+
attrs := oauthAppRegisterAttrs(name, redirectURIs, c.StringSlice("scope"), c.StringSlice("grant"), !c.Bool("public"))
76+
responses, err := appCtx.Client.Execute("register_client", "oauth_app", daptinClient.JsonApiObject(attrs))
77+
if err != nil {
78+
return err
79+
}
80+
return renderOAuthAppActionResponse(appCtx, responses, false)
81+
},
82+
}
83+
}
84+
85+
func oauthAppListCommand(appCtx *AppContext) *cli.Command {
86+
return &cli.Command{
87+
Name: "list",
88+
Usage: "List OAuth provider client apps without secrets",
89+
Action: func(c *cli.Context) error {
90+
result, err := appCtx.Client.FindAll("oauth_app", daptinClient.DaptinQueryParameters{"page[size]": 100})
91+
if err != nil {
92+
return err
93+
}
94+
rows := client.MapArray(result, "attributes")
95+
rows = render.FilterColumns(rows, []string{"name", "client_id", "redirect_uris", "scopes", "grants", "is_confidential", "is_enabled", "reference_id"})
96+
if appCtx.Quiet {
97+
return printRefs(rows)
98+
}
99+
return appCtx.Renderer.RenderArray(rows)
100+
},
101+
}
102+
}
103+
104+
func oauthAppDescribeCommand(appCtx *AppContext) *cli.Command {
105+
return &cli.Command{
106+
Name: "describe",
107+
Usage: "Describe one OAuth provider client app without secrets",
108+
ArgsUsage: "<name-client-id-or-reference-id>",
109+
Action: func(c *cli.Context) error {
110+
app, err := oauthAppByNameClientOrRef(appCtx, c.Args().Get(0))
111+
if err != nil {
112+
return err
113+
}
114+
return renderSingleAPIObject(appCtx, app)
115+
},
116+
}
117+
}
118+
119+
func oauthAppRotateSecretCommand(appCtx *AppContext) *cli.Command {
120+
return &cli.Command{
121+
Name: "rotate-secret",
122+
Usage: "Rotate a confidential OAuth app client secret",
123+
ArgsUsage: "<name-client-id-or-reference-id>",
124+
Action: func(c *cli.Context) error {
125+
app, err := oauthAppByNameClientOrRef(appCtx, c.Args().Get(0))
126+
if err != nil {
127+
return err
128+
}
129+
ref := refID(app)
130+
if ref == "" {
131+
return fmt.Errorf("oauth_app %q has no reference_id", c.Args().Get(0))
132+
}
133+
responses, err := appCtx.Client.Execute("rotate_client_secret", "oauth_app", daptinClient.JsonApiObject{
134+
"oauth_app_id": ref,
135+
})
136+
if err != nil {
137+
return err
138+
}
139+
return renderOAuthAppActionResponse(appCtx, responses, false)
140+
},
141+
}
142+
}
143+
34144
func oauthConnectCommand(appCtx *AppContext) *cli.Command {
35145
return &cli.Command{
36146
Name: "connect",
@@ -296,6 +406,84 @@ func redirectURLFromResponses(responses []daptinClient.DaptinActionResponse) str
296406
return ""
297407
}
298408

409+
func oauthAppRegisterAttrs(name string, redirectURIs, scopes, grants []string, confidential bool) map[string]interface{} {
410+
attrs := map[string]interface{}{
411+
"name": name,
412+
"redirect_uris": oauthListString(redirectURIs, ""),
413+
"scopes": oauthListString(scopes, "openid profile email"),
414+
"grants": oauthListString(grants, "authorization_code refresh_token"),
415+
"is_confidential": confidential,
416+
}
417+
return attrs
418+
}
419+
420+
func oauthListString(values []string, defaultValue string) string {
421+
parts := make([]string, 0, len(values))
422+
for _, value := range values {
423+
value = strings.ReplaceAll(value, ",", " ")
424+
for _, part := range strings.Fields(value) {
425+
if part != "" {
426+
parts = append(parts, part)
427+
}
428+
}
429+
}
430+
if len(parts) == 0 {
431+
return defaultValue
432+
}
433+
return strings.Join(parts, " ")
434+
}
435+
436+
func oauthAppByNameClientOrRef(appCtx *AppContext, nameClientOrRef string) (map[string]interface{}, error) {
437+
if nameClientOrRef == "" {
438+
return nil, fmt.Errorf("oauth app name, client_id, or reference_id required")
439+
}
440+
if strings.Contains(nameClientOrRef, "-") {
441+
row, err := appCtx.Client.FindOne("oauth_app", nameClientOrRef, nil)
442+
if err == nil {
443+
return row, nil
444+
}
445+
}
446+
if row, err := findOneByField(appCtx, "oauth_app", "client_id", nameClientOrRef); err == nil {
447+
return row, nil
448+
}
449+
return findOneByName(appCtx, "oauth_app", nameClientOrRef)
450+
}
451+
452+
func findOneByField(appCtx *AppContext, entityName, fieldName, value string) (map[string]interface{}, error) {
453+
clauses, err := ParseFilter(fieldName + "=" + value)
454+
if err != nil {
455+
return nil, err
456+
}
457+
result, err := appCtx.Client.FindAll(entityName, daptinClient.DaptinQueryParameters{
458+
"page[size]": 1,
459+
"query": FilterToJSON(clauses),
460+
})
461+
if err != nil {
462+
return nil, err
463+
}
464+
if len(result) == 0 {
465+
return nil, fmt.Errorf("%s with %s %q not found", entityName, fieldName, value)
466+
}
467+
return result[0], nil
468+
}
469+
470+
func renderOAuthAppActionResponse(appCtx *AppContext, responses []daptinClient.DaptinActionResponse, redact bool) error {
471+
for _, response := range responses {
472+
if response.ResponseType != "oauth_app" {
473+
continue
474+
}
475+
attrs := response.Attributes
476+
if redact {
477+
attrs = redactSecretColumns(attrs)
478+
}
479+
if appCtx.Quiet {
480+
return printRef(attrs)
481+
}
482+
return appCtx.Renderer.RenderObject(attrs)
483+
}
484+
return applyEffects(ProcessResponses(responses), appCtx)
485+
}
486+
299487
func openBrowser(url string) error {
300488
var command string
301489
var args []string

cmd/oauth_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,32 @@ func TestOAuthClientSecretRequiresSingleSource(t *testing.T) {
3939
}
4040
}
4141

42+
func TestOAuthAppRegisterAttrsDefaults(t *testing.T) {
43+
attrs := oauthAppRegisterAttrs("App Login", []string{"https://app.example.com/auth/daptin/callback"}, nil, nil, true)
44+
if attrs["name"] != "App Login" {
45+
t.Fatalf("unexpected name: %v", attrs["name"])
46+
}
47+
if attrs["redirect_uris"] != "https://app.example.com/auth/daptin/callback" {
48+
t.Fatalf("unexpected redirect_uris: %v", attrs["redirect_uris"])
49+
}
50+
if attrs["scopes"] != "openid profile email" {
51+
t.Fatalf("unexpected scopes: %v", attrs["scopes"])
52+
}
53+
if attrs["grants"] != "authorization_code refresh_token" {
54+
t.Fatalf("unexpected grants: %v", attrs["grants"])
55+
}
56+
if attrs["is_confidential"] != true {
57+
t.Fatalf("expected confidential client, got %v", attrs["is_confidential"])
58+
}
59+
}
60+
61+
func TestOAuthListStringAcceptsRepeatedCommaAndSpaceValues(t *testing.T) {
62+
got := oauthListString([]string{"openid,profile", "email offline_access"}, "")
63+
if got != "openid profile email offline_access" {
64+
t.Fatalf("unexpected list: %q", got)
65+
}
66+
}
67+
4268
func TestRedirectURLFromResponses(t *testing.T) {
4369
responses := []daptinClient.DaptinActionResponse{
4470
{

0 commit comments

Comments
 (0)