Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
170 changes: 170 additions & 0 deletions cli/internal/cmd/oauthclient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package cmd

import (
"fmt"
"strings"

"github.com/managoat/fountain/cli/api"
"github.com/managoat/fountain/cli/internal/output"
"github.com/spf13/cobra"
)

func init() {
oauthClientCmd := &cobra.Command{
Use: "oauth-client",
Short: "Manage the OAuth apps this account registered",
Long: "Register an app so it can offer \"Sign in with Fountain\" against this\n" +
"server, without an operator editing OAUTH_CLIENTS. Registering also lets\n" +
"the app call the API from its own origin.\n\n" +
"An app starts in development mode: it signs in its owner and refuses every\n" +
"other account. Use a sandbox's public HTTPS URL or an HTTP localhost URL.\n" +
"A loopback URI matches on any port.",
}

listCmd := &cobra.Command{
Use: "list",
Short: "List OAuth apps",
RunE: func(cmd *cobra.Command, args []string) error { return oauthClientList(cmd) },
}
listCmd.Flags().Bool("json", false, "output JSON")

createCmd := &cobra.Command{
Use: "create <name>",
Short: "Register an OAuth app",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { return oauthClientCreate(cmd, args[0]) },
}
// StringArray, not StringSlice: StringSlice parses each occurrence as CSV,
// so a redirect URI carrying a comma in its query string would arrive as
// two URIs, the second of them nonsense.
createCmd.Flags().StringArray("redirect-uri", nil, "where the code is returned to; repeatable")
createCmd.Flags().Bool("json", false, "output JSON")
_ = createCmd.MarkFlagRequired("redirect-uri")

updateCmd := &cobra.Command{
Use: "update <id>",
Short: "Rename an OAuth app or replace its redirect URIs",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { return oauthClientUpdate(cmd, args[0]) },
}
updateCmd.Flags().String("name", "", "new name")
updateCmd.Flags().StringArray("redirect-uri", nil, "replaces every redirect URI; repeatable")
updateCmd.Flags().Bool("json", false, "output JSON")

oauthClientCmd.AddCommand(
listCmd,
createCmd,
updateCmd,
&cobra.Command{
Use: "delete <id>",
Short: "Unregister an OAuth app",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { return oauthClientDelete(args[0]) },
},
)
rootCmd.AddCommand(oauthClientCmd)
}

// oauthClient mirrors FountainWeb.OAuthClientJSON.show/1
// (apps/fountain/lib/fountain_web/controllers/oauth_client_json.ex). `id` is
// the record id these commands take; `client_id` is what goes in the app.
type oauthClient struct {
ID string `json:"id"`
ClientID string `json:"client_id"`
Name string `json:"name"`
RedirectURIs []string `json:"redirect_uris"`
Origins []string `json:"origins"`
Published bool `json:"published"`
CreatedAt string `json:"created_at"`
}

func oauthClientList(cmd *cobra.Command) error {
jsonOut, _ := cmd.Flags().GetBool("json")
c := activeClient()
var resp struct {
Data []oauthClient `json:"data"`
}
if err := c.Get("/oauth/clients", &resp); err != nil {
Fatal(err.Error())
}
if jsonOut {
return output.PrintJSON(resp.Data)
}
rows := make([][]string, 0, len(resp.Data))
for _, a := range resp.Data {
rows = append(rows, []string{a.ID, a.ClientID, a.Name, mode(a), strings.Join(a.RedirectURIs, " ")})
}
output.Table([]string{"id", "client_id", "name", "mode", "redirect_uris"}, rows)
return nil
}

func oauthClientCreate(cmd *cobra.Command, name string) error {
uris, _ := cmd.Flags().GetStringArray("redirect-uri")
jsonOut, _ := cmd.Flags().GetBool("json")
c := activeClient()
var resp oauthClient
body := map[string]any{"name": name, "redirect_uris": uris}
if err := c.Post("/oauth/clients", body, &resp); err != nil {
Fatal(err.Error())
}
if jsonOut {
return output.PrintJSON(resp)
}
printOAuthClient(resp)
return nil
}

func oauthClientUpdate(cmd *cobra.Command, id string) error {
jsonOut, _ := cmd.Flags().GetBool("json")
body := map[string]any{}
if name, _ := cmd.Flags().GetString("name"); name != "" {
body["name"] = name
}
if uris, _ := cmd.Flags().GetStringArray("redirect-uri"); len(uris) > 0 {
body["redirect_uris"] = uris
}
if len(body) == 0 {
Fatal("nothing to change: pass --name or --redirect-uri")
}
c := activeClient()
var resp oauthClient
if err := c.Patch("/oauth/clients/"+id, body, &resp); err != nil {
if api.StatusCode(err) == 404 {
Fatalf("app not found: %s", id)
}
Fatal(err.Error())
}
if jsonOut {
return output.PrintJSON(resp)
}
printOAuthClient(resp)
return nil
}

func oauthClientDelete(id string) error {
c := activeClient()
if err := c.Delete("/oauth/clients/"+id, nil); err != nil {
if api.StatusCode(err) == 404 {
Fatalf("app not found: %s", id)
}
Fatal(err.Error())
}
fmt.Printf("Deleted %s. Keys it already issued stay valid until revoked.\n", id)
return nil
}

func mode(a oauthClient) string {
if a.Published {
return "published"
}
return "development"
}

func printOAuthClient(a oauthClient) {
fmt.Printf("id: %s\n", a.ID)
fmt.Printf("client_id: %s\n", a.ClientID)
fmt.Printf("name: %s\n", a.Name)
fmt.Printf("mode: %s\n", mode(a))
fmt.Printf("redirect_uris: %s\n", strings.Join(a.RedirectURIs, "\n "))
fmt.Printf("origins: %s\n", strings.Join(a.Origins, ", "))
}
20 changes: 20 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,26 @@ fountain keys create <name> # prints the key once; it is not recoverable
fountain keys revoke <id>
```

## OAuth apps

Register an app that offers "Sign in with Fountain." The registration also
admits the app's redirect origins to the API.

```bash
fountain oauth-client list [--json]
fountain oauth-client create <name> --redirect-uri <url> [--redirect-uri <url>]... [--json]
fountain oauth-client update <id> [--name <name>] [--redirect-uri <url>]... [--json]
fountain oauth-client delete <id>
```

`create` prints the generated `client_id` that your app sends. A redirect
URI must match exactly and must use `https`. Loopback hosts can use `http`
and match on any port.

The app starts in development mode. It signs in only the account that
registered it. `delete` stops new sign-ins, but keys already issued stay
valid until you revoke them with `fountain keys revoke`.

## Webhooks

Endpoints that Fountain sends conversation lifecycle events to. The
Expand Down
53 changes: 53 additions & 0 deletions docs/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,59 @@ Revoke an API key
fountain keys revoke <id>
```

## `fountain oauth-client create`

Register an OAuth app

```
fountain oauth-client create <name> [flags]
```

Options:

```
--json output JSON
--redirect-uri stringArray where the code is returned to; repeatable
```

## `fountain oauth-client delete`

Unregister an OAuth app

```
fountain oauth-client delete <id>
```

## `fountain oauth-client list`

List OAuth apps

```
fountain oauth-client list [flags]
```

Options:

```
--json output JSON
```

## `fountain oauth-client update`

Rename an OAuth app or replace its redirect URIs

```
fountain oauth-client update <id> [flags]
```

Options:

```
--json output JSON
--name string new name
--redirect-uri stringArray replaces every redirect URI; repeatable
```

## `fountain quickstart`

Run the first request against your default agent and stream the reply
Expand Down
Loading