|
| 1 | +package client |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "fmt" |
| 7 | + |
| 8 | + "github.com/hashicorp/terraform-plugin-log/tflog" |
| 9 | +) |
| 10 | + |
| 11 | +// OAuthAppNotFound detects the error returned when an OAuth app does not |
| 12 | +// exist. Unusually, the get endpoint reports a missing app as HTTP 400 with |
| 13 | +// code "invalid_client" (the OAuth-style error), while delete uses a plain |
| 14 | +// 404 — treat both as not-found. |
| 15 | +func OAuthAppNotFound(err error) bool { |
| 16 | + var apiErr APIError |
| 17 | + return err != nil && errors.As(err, &apiErr) && (apiErr.StatusCode == 404 || apiErr.Code == "invalid_client") |
| 18 | +} |
| 19 | + |
| 20 | +// OAuthAppClientSecretMetadata contains the non-sensitive metadata the API |
| 21 | +// exposes about an OAuth app's client secrets. The secret value itself is only |
| 22 | +// ever returned once, by CreateOAuthAppSecret. |
| 23 | +type OAuthAppClientSecretMetadata struct { |
| 24 | + ID string `json:"id"` |
| 25 | + LastFourChars string `json:"lastFourChars"` |
| 26 | +} |
| 27 | + |
| 28 | +// OAuthApp represents a "Sign in with Vercel" OAuth application. |
| 29 | +type OAuthApp struct { |
| 30 | + ClientID string `json:"clientId"` |
| 31 | + TeamID string `json:"teamId"` |
| 32 | + Name string `json:"name"` |
| 33 | + Slug string `json:"slug"` |
| 34 | + Description string `json:"description"` |
| 35 | + HomePageURI string `json:"homePageUri"` |
| 36 | + RedirectURIs []string `json:"redirectUris"` |
| 37 | + Scopes []string `json:"scopes"` |
| 38 | + PrivacyPolicyURL string `json:"privacyPolicyUrl"` |
| 39 | + TermsOfServiceURL string `json:"termsOfServiceUrl"` |
| 40 | + CodeOfConductURL string `json:"codeOfConductUrl"` |
| 41 | + ClientSecrets []OAuthAppClientSecretMetadata `json:"clientSecrets"` |
| 42 | +} |
| 43 | + |
| 44 | +type CreateOAuthAppRequest struct { |
| 45 | + TeamID string `json:"-"` |
| 46 | + Name string `json:"name"` |
| 47 | + Slug string `json:"slug"` |
| 48 | + Description string `json:"description,omitempty"` |
| 49 | + HomePageURI string `json:"homePageUri,omitempty"` |
| 50 | + RedirectURIs []string `json:"redirectUris,omitempty"` |
| 51 | + Scopes []string `json:"scopes,omitempty"` |
| 52 | + PrivacyPolicyURL string `json:"privacyPolicyUrl,omitempty"` |
| 53 | + TermsOfServiceURL string `json:"termsOfServiceUrl,omitempty"` |
| 54 | + CodeOfConductURL string `json:"codeOfConductUrl,omitempty"` |
| 55 | +} |
| 56 | + |
| 57 | +// UpdateOAuthAppRequest updates an OAuth app. Nullable URL fields are pointers |
| 58 | +// WITHOUT omitempty: an explicit JSON null is how the API clears a previously |
| 59 | +// set value, so unset (nil) pointers are serialized as null deliberately. |
| 60 | +type UpdateOAuthAppRequest struct { |
| 61 | + TeamID string `json:"-"` |
| 62 | + ClientID string `json:"-"` |
| 63 | + Name string `json:"name"` |
| 64 | + Slug string `json:"slug"` |
| 65 | + Description string `json:"description"` |
| 66 | + HomePageURI *string `json:"homePageUri"` |
| 67 | + RedirectURIs []string `json:"redirectUris"` |
| 68 | + Scopes []string `json:"scopes"` |
| 69 | + PrivacyPolicyURL *string `json:"privacyPolicyUrl"` |
| 70 | + TermsOfServiceURL *string `json:"termsOfServiceUrl"` |
| 71 | + CodeOfConductURL *string `json:"codeOfConductUrl"` |
| 72 | +} |
| 73 | + |
| 74 | +// OAuthAppSecret is the response of generating a client secret. This is the |
| 75 | +// ONLY time the API returns the plaintext secret; subsequent reads expose just |
| 76 | +// its last four characters. |
| 77 | +type OAuthAppSecret struct { |
| 78 | + ClientID string `json:"clientId"` |
| 79 | + ClientSecret string `json:"clientSecret"` |
| 80 | +} |
| 81 | + |
| 82 | +func (c *Client) CreateOAuthApp(ctx context.Context, request CreateOAuthAppRequest) (a OAuthApp, err error) { |
| 83 | + url := fmt.Sprintf("%s/v1/oauth-apps", c.baseURL) |
| 84 | + if c.TeamID(request.TeamID) != "" { |
| 85 | + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(request.TeamID)) |
| 86 | + } |
| 87 | + payload := string(mustMarshal(request)) |
| 88 | + tflog.Info(ctx, "creating oauth app", map[string]any{ |
| 89 | + "url": url, |
| 90 | + "payload": payload, |
| 91 | + }) |
| 92 | + err = c.doRequest(clientRequest{ |
| 93 | + ctx: ctx, |
| 94 | + method: "POST", |
| 95 | + url: url, |
| 96 | + body: payload, |
| 97 | + }, &a) |
| 98 | + return a, err |
| 99 | +} |
| 100 | + |
| 101 | +func (c *Client) GetOAuthApp(ctx context.Context, clientID, teamID string) (OAuthApp, error) { |
| 102 | + url := fmt.Sprintf("%s/v1/oauth-apps/%s", c.baseURL, clientID) |
| 103 | + if c.TeamID(teamID) != "" { |
| 104 | + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(teamID)) |
| 105 | + } |
| 106 | + tflog.Info(ctx, "getting oauth app", map[string]any{ |
| 107 | + "url": url, |
| 108 | + }) |
| 109 | + // Unlike create/update, the get endpoint wraps the app in an envelope. |
| 110 | + var response struct { |
| 111 | + App OAuthApp `json:"app"` |
| 112 | + } |
| 113 | + err := c.doRequest(clientRequest{ |
| 114 | + ctx: ctx, |
| 115 | + method: "GET", |
| 116 | + url: url, |
| 117 | + }, &response) |
| 118 | + return response.App, err |
| 119 | +} |
| 120 | + |
| 121 | +func (c *Client) UpdateOAuthApp(ctx context.Context, request UpdateOAuthAppRequest) (a OAuthApp, err error) { |
| 122 | + url := fmt.Sprintf("%s/v1/oauth-apps/%s", c.baseURL, request.ClientID) |
| 123 | + if c.TeamID(request.TeamID) != "" { |
| 124 | + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(request.TeamID)) |
| 125 | + } |
| 126 | + payload := string(mustMarshal(request)) |
| 127 | + tflog.Info(ctx, "updating oauth app", map[string]any{ |
| 128 | + "url": url, |
| 129 | + "payload": payload, |
| 130 | + }) |
| 131 | + err = c.doRequest(clientRequest{ |
| 132 | + ctx: ctx, |
| 133 | + method: "PATCH", |
| 134 | + url: url, |
| 135 | + body: payload, |
| 136 | + }, &a) |
| 137 | + return a, err |
| 138 | +} |
| 139 | + |
| 140 | +func (c *Client) DeleteOAuthApp(ctx context.Context, clientID, teamID string) error { |
| 141 | + url := fmt.Sprintf("%s/v1/oauth-apps/%s", c.baseURL, clientID) |
| 142 | + if c.TeamID(teamID) != "" { |
| 143 | + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(teamID)) |
| 144 | + } |
| 145 | + tflog.Info(ctx, "deleting oauth app", map[string]any{ |
| 146 | + "url": url, |
| 147 | + }) |
| 148 | + return c.doRequest(clientRequest{ |
| 149 | + ctx: ctx, |
| 150 | + method: "DELETE", |
| 151 | + url: url, |
| 152 | + }, nil) |
| 153 | +} |
| 154 | + |
| 155 | +func (c *Client) CreateOAuthAppSecret(ctx context.Context, clientID, teamID string) (s OAuthAppSecret, err error) { |
| 156 | + url := fmt.Sprintf("%s/v1/oauth-apps/%s/secret", c.baseURL, clientID) |
| 157 | + if c.TeamID(teamID) != "" { |
| 158 | + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(teamID)) |
| 159 | + } |
| 160 | + tflog.Info(ctx, "creating oauth app client secret", map[string]any{ |
| 161 | + "url": url, |
| 162 | + }) |
| 163 | + // The endpoint takes no parameters but rejects body-less requests with |
| 164 | + // 415 Unsupported Media Type — send an empty JSON object. |
| 165 | + err = c.doRequest(clientRequest{ |
| 166 | + ctx: ctx, |
| 167 | + method: "POST", |
| 168 | + url: url, |
| 169 | + body: "{}", |
| 170 | + }, &s) |
| 171 | + return s, err |
| 172 | +} |
| 173 | + |
| 174 | +// DeleteOAuthAppSecret deletes a client secret. The API identifies secrets by |
| 175 | +// the LAST FOUR CHARACTERS of the secret value, not by id. |
| 176 | +func (c *Client) DeleteOAuthAppSecret(ctx context.Context, clientID, lastFourChars, teamID string) error { |
| 177 | + url := fmt.Sprintf("%s/v1/oauth-apps/%s/secret/%s", c.baseURL, clientID, lastFourChars) |
| 178 | + if c.TeamID(teamID) != "" { |
| 179 | + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(teamID)) |
| 180 | + } |
| 181 | + tflog.Info(ctx, "deleting oauth app client secret", map[string]any{ |
| 182 | + "url": url, |
| 183 | + }) |
| 184 | + return c.doRequest(clientRequest{ |
| 185 | + ctx: ctx, |
| 186 | + method: "DELETE", |
| 187 | + url: url, |
| 188 | + }, nil) |
| 189 | +} |
0 commit comments