Skip to content

Commit 0be7d9a

Browse files
authored
feat: support specifying client_id on oauth2 client creation (#128)
Allow users to specify a custom `client_id` when creating an OAuth2 client resource, matching the Ory API capability. The field is Optional+Computed with RequiresReplace, so changing it forces recreation. Closes #121
1 parent 2c6a7ec commit 0be7d9a

File tree

7 files changed

+125
-2
lines changed

7 files changed

+125
-2
lines changed

docs/resources/oauth2_client.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ OAuth2 clients are used for machine-to-machine authentication or user-facing OAu
1515

1616
~> **Important:** The `client_secret` is only returned when the client is first created. Store it securely immediately after creation. It cannot be retrieved later, including after `terraform import`.
1717

18+
## Custom Client ID
19+
20+
By default, a random `client_id` is generated when the client is created. You can specify a custom `client_id` for consistency across environments:
21+
22+
```hcl
23+
resource "ory_oauth2_client" "api" {
24+
client_id = "my-api-client"
25+
client_name = "API Client"
26+
grant_types = ["client_credentials"]
27+
scope = "read write"
28+
}
29+
```
30+
31+
~> **Note:** Changing the `client_id` after creation forces the resource to be destroyed and recreated. The `client_id` must be unique within the project.
32+
1833
## Example Usage
1934

2035
```terraform
@@ -141,6 +156,14 @@ resource "ory_oauth2_client" "cli_tool" {
141156
scope = "openid offline_access"
142157
}
143158
159+
# Client with a custom client_id (useful for consistency across environments)
160+
resource "ory_oauth2_client" "custom_id" {
161+
client_id = "my-api-client"
162+
client_name = "API Client with Custom ID"
163+
grant_types = ["client_credentials"]
164+
scope = "api:read api:write"
165+
}
166+
144167
# Same-apply: Create project and OAuth2 client together
145168
# Use resource-level credentials when the project doesn't exist yet
146169
resource "ory_oauth2_client" "same_apply" {
@@ -329,6 +352,7 @@ terraform import ory_oauth2_client.api <client-id>
329352
- `backchannel_logout_session_required` (Boolean) Whether the client requires a session identifier in back-channel logout notifications.
330353
- `backchannel_logout_uri` (String) OpenID Connect back-channel logout URI.
331354
- `client_credentials_grant_access_token_lifespan` (String) Access token lifespan for client credentials grant (e.g., '1h', '30m').
355+
- `client_id` (String) The OAuth2 client ID. If not specified, a random ID will be generated. Once set, changing this value forces recreation of the resource.
332356
- `client_uri` (String) URL of the client's homepage.
333357
- `contacts` (List of String) List of contact email addresses for the client maintainers.
334358
- `device_authorization_grant_access_token_lifespan` (String) Access token lifespan for device authorization grant (e.g., '1h').
@@ -364,6 +388,5 @@ terraform import ory_oauth2_client.api <client-id>
364388

365389
### Read-Only
366390

367-
- `client_id` (String) The OAuth2 client ID.
368391
- `client_secret` (String, Sensitive) The OAuth2 client secret. Only returned on creation.
369392
- `id` (String) Internal Terraform ID (same as client_id).

examples/resources/ory_oauth2_client/resource.tf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ resource "ory_oauth2_client" "cli_tool" {
121121
scope = "openid offline_access"
122122
}
123123

124+
# Client with a custom client_id (useful for consistency across environments)
125+
resource "ory_oauth2_client" "custom_id" {
126+
client_id = "my-api-client"
127+
client_name = "API Client with Custom ID"
128+
grant_types = ["client_credentials"]
129+
scope = "api:read api:write"
130+
}
131+
124132
# Same-apply: Create project and OAuth2 client together
125133
# Use resource-level credentials when the project doesn't exist yet
126134
resource "ory_oauth2_client" "same_apply" {

internal/resources/oauth2client/resource.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ resource "ory_oauth2_client" "api" {
115115
token_endpoint_auth_method = "client_secret_post"
116116
}
117117
118+
# With a custom client_id
119+
resource "ory_oauth2_client" "custom" {
120+
client_id = "my-api-client"
121+
client_name = "API Client with Custom ID"
122+
grant_types = ["client_credentials"]
123+
scope = "read write"
124+
}
125+
118126
output "client_id" {
119127
value = ory_oauth2_client.api.client_id
120128
}
@@ -149,10 +157,12 @@ func (r *OAuth2ClientResource) Schema(ctx context.Context, req resource.SchemaRe
149157
},
150158
},
151159
"client_id": schema.StringAttribute{
152-
Description: "The OAuth2 client ID.",
160+
Description: "The OAuth2 client ID. If not specified, a random ID will be generated. Once set, changing this value forces recreation of the resource.",
161+
Optional: true,
153162
Computed: true,
154163
PlanModifiers: []planmodifier.String{
155164
stringplanmodifier.UseStateForUnknown(),
165+
stringplanmodifier.RequiresReplace(),
156166
},
157167
},
158168
"client_secret": schema.StringAttribute{
@@ -407,6 +417,10 @@ func (r *OAuth2ClientResource) Create(ctx context.Context, req resource.CreateRe
407417
Scope: ory.PtrString(plan.Scope.ValueString()),
408418
}
409419

420+
if !plan.ClientID.IsNull() && !plan.ClientID.IsUnknown() {
421+
oauthClient.ClientId = ory.PtrString(plan.ClientID.ValueString())
422+
}
423+
410424
if !plan.GrantTypes.IsNull() && !plan.GrantTypes.IsUnknown() {
411425
var grantTypes []string
412426
resp.Diagnostics.Append(plan.GrantTypes.ElementsAs(ctx, &grantTypes, false)...)

internal/resources/oauth2client/resource_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
package oauth2client_test
44

55
import (
6+
"fmt"
67
"testing"
8+
"time"
79

810
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
911

@@ -227,6 +229,51 @@ func TestAccOAuth2ClientResource_withResourceCredentials(t *testing.T) {
227229
})
228230
}
229231

232+
func TestAccOAuth2ClientResource_withCustomClientID(t *testing.T) {
233+
clientID := fmt.Sprintf("tf-acc-test-%d", time.Now().UnixNano())
234+
235+
acctest.RunTest(t, resource.TestCase{
236+
PreCheck: func() { acctest.AccPreCheck(t) },
237+
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories(),
238+
Steps: []resource.TestStep{
239+
// Create with custom client_id
240+
{
241+
Config: acctest.LoadTestConfig(t, "testdata/with_custom_client_id.tf.tmpl", map[string]string{
242+
"Name": "Test Client Custom ID",
243+
"ClientID": clientID,
244+
}),
245+
Check: resource.ComposeAggregateTestCheckFunc(
246+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "client_id", clientID),
247+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "id", clientID),
248+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "client_name", "Test Client Custom ID"),
249+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "scope", "api:read"),
250+
resource.TestCheckResourceAttrSet("ory_oauth2_client.test", "client_secret"),
251+
),
252+
},
253+
// ImportState
254+
{
255+
ResourceName: "ory_oauth2_client.test",
256+
ImportState: true,
257+
ImportStateVerify: true,
258+
ImportStateVerifyIgnore: []string{"client_secret"},
259+
},
260+
// Update (change name/scope, client_id stays the same)
261+
{
262+
Config: acctest.LoadTestConfig(t, "testdata/with_custom_client_id_updated.tf.tmpl", map[string]string{
263+
"Name": "Test Client Custom ID Updated",
264+
"ClientID": clientID,
265+
}),
266+
Check: resource.ComposeAggregateTestCheckFunc(
267+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "client_id", clientID),
268+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "id", clientID),
269+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "client_name", "Test Client Custom ID Updated"),
270+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "scope", "api:read api:write"),
271+
),
272+
},
273+
},
274+
})
275+
}
276+
230277
func TestAccOAuth2ClientResource_withTokenLifespans(t *testing.T) {
231278
acctest.RunTest(t, resource.TestCase{
232279
PreCheck: func() { acctest.AccPreCheck(t) },
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
resource "ory_oauth2_client" "test" {
2+
client_id = "[[ .ClientID ]]"
3+
client_name = "[[ .Name ]]"
4+
5+
grant_types = ["client_credentials"]
6+
response_types = ["token"]
7+
scope = "api:read"
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
resource "ory_oauth2_client" "test" {
2+
client_id = "[[ .ClientID ]]"
3+
client_name = "[[ .Name ]]"
4+
5+
grant_types = ["client_credentials"]
6+
response_types = ["token"]
7+
scope = "api:read api:write"
8+
}

templates/resources/oauth2_client.md.tmpl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ OAuth2 clients are used for machine-to-machine authentication or user-facing OAu
1515

1616
~> **Important:** The `client_secret` is only returned when the client is first created. Store it securely immediately after creation. It cannot be retrieved later, including after `terraform import`.
1717

18+
## Custom Client ID
19+
20+
By default, a random `client_id` is generated when the client is created. You can specify a custom `client_id` for consistency across environments:
21+
22+
```hcl
23+
resource "ory_oauth2_client" "api" {
24+
client_id = "my-api-client"
25+
client_name = "API Client"
26+
grant_types = ["client_credentials"]
27+
scope = "read write"
28+
}
29+
```
30+
31+
~> **Note:** Changing the `client_id` after creation forces the resource to be destroyed and recreated. The `client_id` must be unique within the project.
32+
1833
## Example Usage
1934

2035
{{ tffile "examples/resources/ory_oauth2_client/resource.tf" }}

0 commit comments

Comments
 (0)