Skip to content

Commit c8a6711

Browse files
authored
Add vercel_oauth_app and vercel_oauth_app_client_secret resources (#576)
* Add vercel_oauth_app and vercel_oauth_app_client_secret resources Adds Terraform support for Sign in with Vercel OAuth applications (https://vercel.com/docs/sign-in-with-vercel), which until now could only be created in the dashboard. vercel_oauth_app manages the application itself: name, slug, description, home page URI, redirect (callback) URIs, scopes (openid/email/profile/ offline_access), and the consent-page policy URLs. The resource id is the OAuth client_id. Supports import as [team_id/]client_id. A validator enforces that an explicitly configured scopes set includes "openid", since the API force-includes it server-side (omitting it would cause a perpetual diff); nullable URL fields are cleared with explicit JSON nulls on update. vercel_oauth_app_client_secret generates a client secret. The API returns the plaintext exactly once, so it is captured at create time (sensitive) and subsequently tracked via the secret's last four characters — which is also how the delete endpoint addresses secrets. Apps allow at most two secrets, enabling zero-downtime rotation with create_before_destroy. Not importable by nature. API notes baked into the client: the get endpoint wraps its response in { app }, reports missing apps as HTTP 400 code "invalid_client" rather than 404 (handled by a dedicated OAuthAppNotFound helper), and the secret endpoint rejects body-less POSTs with 415, so an empty JSON object is sent. Both acceptance tests pass against the production API (create, import, update, secret generation/verification, destroy). Note the API requires the team Owner role for all mutations, including in acceptance test runs. * Address review: guard set conversions, add id to client secret resource - Guard all ElementsAs conversions on redirect_uris/scopes against null AND unknown values. Verified empirically that ElementsAs handles a null Set fine (yields an empty slice, no diagnostics) — the real hazard is UNKNOWN values, which scopes (Optional+Computed) can genuinely be at create time. The guards are now uniform across Create and Update, and the acceptance test's first step is a minimal config (no redirect_uris, no scopes) so the flagged path is exercised for real. - Add the computed id attribute to vercel_oauth_app_client_secret (Pulumi bridge compatibility; fixes TestAllResourcesHaveIDAttribute). The id is the API's secret metadata id, resolved from the app's secret list at create time, with a synthetic <client_id>/<last_four> fallback should the metadata omit it. Unit tests, staticcheck, tfproviderlint, gofmt -s, and go vet are clean; both acceptance tests re-verified against the production API.
1 parent 7376169 commit c8a6711

11 files changed

Lines changed: 1366 additions & 0 deletions

File tree

client/oauth_app.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
}

docs/resources/oauth_app.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "vercel_oauth_app Resource - terraform-provider-vercel"
4+
subcategory: ""
5+
description: |-
6+
Provides an OAuth App resource for Sign in with Vercel https://vercel.com/docs/sign-in-with-vercel.
7+
An OAuth App lets people use their Vercel account to log in to your application via OAuth 2.0 / OpenID Connect. Use the vercel_oauth_app_client_secret resource to generate the client secret your application authenticates with.
8+
~> Managing OAuth Apps requires the Owner role on the team.
9+
---
10+
11+
# vercel_oauth_app (Resource)
12+
13+
Provides an OAuth App resource for [Sign in with Vercel](https://vercel.com/docs/sign-in-with-vercel).
14+
15+
An OAuth App lets people use their Vercel account to log in to your application via OAuth 2.0 / OpenID Connect. Use the `vercel_oauth_app_client_secret` resource to generate the client secret your application authenticates with.
16+
17+
~> Managing OAuth Apps requires the Owner role on the team.
18+
19+
## Example Usage
20+
21+
```terraform
22+
resource "vercel_oauth_app" "example" {
23+
name = "My Example App"
24+
slug = "my-example-app"
25+
26+
description = "Lets users sign in to Example with their Vercel account."
27+
home_page_uri = "https://example.com"
28+
29+
redirect_uris = [
30+
"https://example.com/api/auth/callback",
31+
"http://localhost:3000/api/auth/callback",
32+
]
33+
34+
# "openid" is always required; "offline_access" issues refresh tokens.
35+
scopes = ["openid", "email", "profile", "offline_access"]
36+
37+
privacy_policy_url = "https://example.com/privacy"
38+
terms_of_service_url = "https://example.com/terms"
39+
}
40+
```
41+
42+
<!-- schema generated by tfplugindocs -->
43+
## Schema
44+
45+
### Required
46+
47+
- `name` (String) A human-readable name for the OAuth App, shown on the consent page.
48+
- `slug` (String) A URL-friendly slug for the OAuth App. Must be unique.
49+
50+
### Optional
51+
52+
- `code_of_conduct_url` (String) The URL of the application's code of conduct.
53+
- `description` (String) A description of the OAuth App, shown on the consent page.
54+
- `home_page_uri` (String) The URL of the application's home page.
55+
- `privacy_policy_url` (String) The URL of the application's privacy policy, shown on the consent page.
56+
- `redirect_uris` (Set of String) The authorization callback URLs of the OAuth App. Must be absolute `https` URLs (`http` is allowed for loopback addresses only).
57+
- `scopes` (Set of String) The scopes the OAuth App may request: `openid` (always required), `email`, `profile`, and `offline_access` (issues refresh tokens). Defaults to `["openid"]`.
58+
- `team_id` (String) The ID of the team the OAuth App should exist under. Required when configuring a team resource if a default team has not been set in the provider.
59+
- `terms_of_service_url` (String) The URL of the application's terms of service, shown on the consent page.
60+
61+
### Read-Only
62+
63+
- `id` (String) The client ID of the OAuth App (`cl_...`). Use this as the OAuth `client_id`.
64+
65+
## Import
66+
67+
Import is supported using the following syntax:
68+
69+
The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
70+
71+
```shell
72+
# If importing into a personal account, or with a team configured on
73+
# the provider, simply use the client_id.
74+
terraform import vercel_oauth_app.example cl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
75+
76+
# If importing to a team, use the team_id and client_id.
77+
terraform import vercel_oauth_app.example team_xxxxxxxxxxxxxxxxxxxxxxxx/cl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
78+
```
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "vercel_oauth_app_client_secret Resource - terraform-provider-vercel"
4+
subcategory: ""
5+
description: |-
6+
Provides a client secret for a vercel_oauth_app (Sign in with Vercel https://vercel.com/docs/sign-in-with-vercel).
7+
The secret value is only ever returned by the API at creation time and is stored (marked sensitive) in the Terraform state. An OAuth App can have at most two client secrets at a time, so zero-downtime rotation is possible by creating a new secret before destroying the old one (e.g. with terraform apply -replace and create_before_destroy).
8+
~> Managing client secrets requires the Owner role on the team.
9+
-> This resource cannot be imported, as the API never re-exposes the secret value.
10+
---
11+
12+
# vercel_oauth_app_client_secret (Resource)
13+
14+
Provides a client secret for a `vercel_oauth_app` ([Sign in with Vercel](https://vercel.com/docs/sign-in-with-vercel)).
15+
16+
The secret value is only ever returned by the API at creation time and is stored (marked sensitive) in the Terraform state. An OAuth App can have at most two client secrets at a time, so zero-downtime rotation is possible by creating a new secret before destroying the old one (e.g. with `terraform apply -replace` and `create_before_destroy`).
17+
18+
~> Managing client secrets requires the Owner role on the team.
19+
20+
-> This resource cannot be imported, as the API never re-exposes the secret value.
21+
22+
## Example Usage
23+
24+
```terraform
25+
resource "vercel_oauth_app" "example" {
26+
name = "My Example App"
27+
slug = "my-example-app"
28+
29+
redirect_uris = ["https://example.com/api/auth/callback"]
30+
scopes = ["openid", "email", "profile", "offline_access"]
31+
}
32+
33+
resource "vercel_oauth_app_client_secret" "example" {
34+
oauth_app_id = vercel_oauth_app.example.id
35+
36+
# An OAuth App can hold at most two secrets at a time, so a replacement
37+
# secret can be created before the old one is destroyed (zero-downtime
38+
# rotation with e.g. `terraform apply -replace=vercel_oauth_app_client_secret.example`).
39+
lifecycle {
40+
create_before_destroy = true
41+
}
42+
}
43+
44+
# Example: feed the credentials to the application consuming them.
45+
resource "vercel_project_environment_variable" "oauth_client_id" {
46+
project_id = vercel_project.example.id
47+
key = "OAUTH_CLIENT_ID"
48+
value = vercel_oauth_app.example.id
49+
target = ["production", "preview", "development"]
50+
}
51+
52+
resource "vercel_project_environment_variable" "oauth_client_secret" {
53+
project_id = vercel_project.example.id
54+
key = "OAUTH_CLIENT_SECRET"
55+
value = vercel_oauth_app_client_secret.example.client_secret
56+
target = ["production", "preview", "development"]
57+
sensitive = true
58+
}
59+
60+
resource "vercel_project" "example" {
61+
name = "example-project"
62+
}
63+
```
64+
65+
<!-- schema generated by tfplugindocs -->
66+
## Schema
67+
68+
### Required
69+
70+
- `oauth_app_id` (String) The client ID of the OAuth App (`cl_...`) to generate a secret for.
71+
72+
### Optional
73+
74+
- `team_id` (String) The ID of the team the OAuth App exists under. Required when configuring a team resource if a default team has not been set in the provider.
75+
76+
### Read-Only
77+
78+
- `client_secret` (String, Sensitive) The generated client secret. Only available at creation time; stored in the Terraform state.
79+
- `id` (String) The unique identifier of the client secret.
80+
- `last_four_chars` (String) The last four characters of the client secret — the identifier the Vercel API and dashboard use to reference this secret.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# If importing into a personal account, or with a team configured on
2+
# the provider, simply use the client_id.
3+
terraform import vercel_oauth_app.example cl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
4+
5+
# If importing to a team, use the team_id and client_id.
6+
terraform import vercel_oauth_app.example team_xxxxxxxxxxxxxxxxxxxxxxxx/cl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
resource "vercel_oauth_app" "example" {
2+
name = "My Example App"
3+
slug = "my-example-app"
4+
5+
description = "Lets users sign in to Example with their Vercel account."
6+
home_page_uri = "https://example.com"
7+
8+
redirect_uris = [
9+
"https://example.com/api/auth/callback",
10+
"http://localhost:3000/api/auth/callback",
11+
]
12+
13+
# "openid" is always required; "offline_access" issues refresh tokens.
14+
scopes = ["openid", "email", "profile", "offline_access"]
15+
16+
privacy_policy_url = "https://example.com/privacy"
17+
terms_of_service_url = "https://example.com/terms"
18+
}

0 commit comments

Comments
 (0)