Skip to content

Commit 09038b1

Browse files
authored
Merge pull request #68 from ory/fix/oauth2-client-nil-pointer-deref
fix: resolve nil pointer dereference when creating OAuth2 client with unknown provider config
2 parents 3d98893 + 3581910 commit 09038b1

File tree

8 files changed

+474
-3
lines changed

8 files changed

+474
-3
lines changed

docs/resources/oauth2_client.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,18 @@ resource "ory_oauth2_client" "cli_tool" {
141141
scope = "openid offline_access"
142142
}
143143
144+
# Same-apply: Create project and OAuth2 client together
145+
# Use resource-level credentials when the project doesn't exist yet
146+
resource "ory_oauth2_client" "same_apply" {
147+
project_slug = ory_project.main.slug
148+
project_api_key = ory_project_api_key.main.value
149+
150+
client_name = "Created with Project"
151+
grant_types = ["client_credentials"]
152+
token_endpoint_auth_method = "client_secret_post"
153+
scope = "api:read api:write"
154+
}
155+
144156
output "api_service_client_id" {
145157
value = ory_oauth2_client.api_service.client_id
146158
}
@@ -262,6 +274,33 @@ The provider supports both OIDC front-channel and back-channel logout:
262274
- `frontchannel_logout_session_required` — Whether the client requires a session identifier (`sid`) in front-channel logout notifications.
263275
- `backchannel_logout_session_required` — Whether the client requires a session identifier (`sid`) in back-channel logout notifications.
264276

277+
## Resource-Level Credentials (Same-Apply with Project Creation)
278+
279+
When creating an `ory_oauth2_client` in the same `terraform apply` as the `ory_project` it belongs to, the provider may not have project credentials at configuration time. Use the `project_slug` and `project_api_key` attributes to pass credentials directly to the resource:
280+
281+
```hcl
282+
resource "ory_project" "main" {
283+
name = "my-project"
284+
environment = "prod"
285+
}
286+
287+
resource "ory_project_api_key" "main" {
288+
project_id = ory_project.main.id
289+
name = "terraform-key"
290+
}
291+
292+
resource "ory_oauth2_client" "api" {
293+
project_slug = ory_project.main.slug
294+
project_api_key = ory_project_api_key.main.value
295+
296+
client_name = "API Client"
297+
grant_types = ["client_credentials"]
298+
scope = "read write"
299+
}
300+
```
301+
302+
These attributes override the provider-level `project_slug` and `project_api_key`. If the provider already has valid project credentials, you do not need to set them on the resource.
303+
265304
## Import
266305

267306
OAuth2 clients can be imported using their client ID:
@@ -307,6 +346,8 @@ terraform import ory_oauth2_client.api <client-id>
307346
- `metadata` (String) Custom metadata as JSON string.
308347
- `policy_uri` (String) URL of the client's privacy policy.
309348
- `post_logout_redirect_uris` (List of String) List of allowed post-logout redirect URIs for OpenID Connect logout.
349+
- `project_api_key` (String, Sensitive) Project API key for API access. Use this to pass credentials at the resource level when the provider is configured before the project exists (e.g., creating a project and OAuth2 client in the same apply). Overrides the provider-level project_api_key.
350+
- `project_slug` (String) Project slug for API access. Use this to pass credentials at the resource level when the provider is configured before the project exists (e.g., creating a project and OAuth2 client in the same apply). Overrides the provider-level project_slug.
310351
- `redirect_uris` (List of String) List of allowed redirect URIs for authorization code flow.
311352
- `refresh_token_grant_access_token_lifespan` (String) Access token lifespan for refresh token grant (e.g., '1h', '30m').
312353
- `refresh_token_grant_id_token_lifespan` (String) ID token lifespan for refresh token grant (e.g., '1h', '30m').

examples/resources/ory_oauth2_client/resource.tf

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

124+
# Same-apply: Create project and OAuth2 client together
125+
# Use resource-level credentials when the project doesn't exist yet
126+
resource "ory_oauth2_client" "same_apply" {
127+
project_slug = ory_project.main.slug
128+
project_api_key = ory_project_api_key.main.value
129+
130+
client_name = "Created with Project"
131+
grant_types = ["client_credentials"]
132+
token_endpoint_auth_method = "client_secret_post"
133+
scope = "api:read api:write"
134+
}
135+
124136
output "api_service_client_id" {
125137
value = ory_oauth2_client.api_service.client_id
126138
}

internal/client/client.go

Lines changed: 134 additions & 3 deletions
Large diffs are not rendered by default.

internal/client/client_test.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,191 @@ func TestIsRateLimitError(t *testing.T) {
314314
}
315315
}
316316

317+
func TestOryClient_EnsureProjectClient_NilWithoutCredentials(t *testing.T) {
318+
cfg := OryClientConfig{
319+
WorkspaceAPIKey: testutil.TestWorkspaceAPIKey,
320+
ConsoleAPIURL: DefaultConsoleAPIURL,
321+
// No ProjectAPIKey or ProjectSlug
322+
}
323+
324+
client, err := NewOryClient(cfg)
325+
if err != nil {
326+
t.Fatalf("unexpected error: %v", err)
327+
}
328+
329+
if client.projectClient != nil {
330+
t.Error("project client should be nil without credentials")
331+
}
332+
333+
err = client.ensureProjectClient()
334+
if err == nil {
335+
t.Fatal("expected error from ensureProjectClient without credentials")
336+
}
337+
if !contains(err.Error(), "project_slug and project_api_key are required") {
338+
t.Errorf("unexpected error message: %s", err.Error())
339+
}
340+
}
341+
342+
func TestOryClient_EnsureProjectClient_LazyInit(t *testing.T) {
343+
cfg := OryClientConfig{
344+
ProjectAPIKey: testutil.TestProjectAPIKey,
345+
ProjectSlug: testutil.TestProjectSlug,
346+
}
347+
348+
client, err := NewOryClient(cfg)
349+
if err != nil {
350+
t.Fatalf("unexpected error: %v", err)
351+
}
352+
353+
// Project client should already be initialized since credentials were provided at creation
354+
if client.projectClient == nil {
355+
t.Error("project client should be initialized when credentials provided at creation")
356+
}
357+
358+
// ensureProjectClient should succeed (client already initialized)
359+
if err := client.ensureProjectClient(); err != nil {
360+
t.Errorf("unexpected error: %v", err)
361+
}
362+
}
363+
364+
func TestOryClient_WithProjectCredentials(t *testing.T) {
365+
cfg := OryClientConfig{
366+
WorkspaceAPIKey: testutil.TestWorkspaceAPIKey,
367+
ConsoleAPIURL: DefaultConsoleAPIURL,
368+
// No project credentials initially
369+
}
370+
371+
parent, err := NewOryClient(cfg)
372+
if err != nil {
373+
t.Fatalf("unexpected error: %v", err)
374+
}
375+
376+
// Create a child client with project credentials
377+
child := parent.WithProjectCredentials(testutil.TestProjectSlug, testutil.TestProjectAPIKey)
378+
379+
// Parent should still have no project credentials
380+
if parent.config.ProjectSlug != "" {
381+
t.Error("parent config should not be modified")
382+
}
383+
384+
// Child should have the new credentials
385+
if child.config.ProjectSlug != testutil.TestProjectSlug {
386+
t.Errorf("expected slug '%s', got '%s'", testutil.TestProjectSlug, child.config.ProjectSlug)
387+
}
388+
if child.config.ProjectAPIKey != testutil.TestProjectAPIKey {
389+
t.Errorf("expected API key '%s', got '%s'", testutil.TestProjectAPIKey, child.config.ProjectAPIKey)
390+
}
391+
392+
// Child should share the parent's console client
393+
if child.consoleClient != parent.consoleClient {
394+
t.Error("child should share the parent's console client")
395+
}
396+
397+
// Child's project client should be lazily initialized
398+
if child.projectClient != nil {
399+
t.Error("child project client should be nil before first use")
400+
}
401+
402+
// ensureProjectClient should initialize the child's project client
403+
if err := child.ensureProjectClient(); err != nil {
404+
t.Fatalf("unexpected error: %v", err)
405+
}
406+
if child.projectClient == nil {
407+
t.Error("child project client should be initialized after ensureProjectClient")
408+
}
409+
410+
// Verify the child's project client URL
411+
servers := child.projectClient.GetConfig().Servers
412+
expectedURL := fmt.Sprintf(DefaultProjectAPIURL, testutil.TestProjectSlug)
413+
if servers[0].URL != expectedURL {
414+
t.Errorf("expected project URL '%s', got '%s'", expectedURL, servers[0].URL)
415+
}
416+
}
417+
418+
func TestOryClient_WithProjectCredentials_Isolation(t *testing.T) {
419+
cfg := OryClientConfig{
420+
ProjectAPIKey: testutil.TestProjectAPIKey,
421+
ProjectSlug: testutil.TestProjectSlug,
422+
}
423+
424+
parent, err := NewOryClient(cfg)
425+
if err != nil {
426+
t.Fatalf("unexpected error: %v", err)
427+
}
428+
429+
// Create two children with different credentials
430+
child1 := parent.WithProjectCredentials("slug-one", "key-one")
431+
child2 := parent.WithProjectCredentials("slug-two", "key-two")
432+
433+
// Initialize both
434+
if err := child1.ensureProjectClient(); err != nil {
435+
t.Fatalf("unexpected error for child1: %v", err)
436+
}
437+
if err := child2.ensureProjectClient(); err != nil {
438+
t.Fatalf("unexpected error for child2: %v", err)
439+
}
440+
441+
// Verify each child has its own project client with correct URL
442+
servers1 := child1.projectClient.GetConfig().Servers
443+
expectedURL1 := fmt.Sprintf(DefaultProjectAPIURL, "slug-one")
444+
if servers1[0].URL != expectedURL1 {
445+
t.Errorf("child1: expected project URL '%s', got '%s'", expectedURL1, servers1[0].URL)
446+
}
447+
448+
servers2 := child2.projectClient.GetConfig().Servers
449+
expectedURL2 := fmt.Sprintf(DefaultProjectAPIURL, "slug-two")
450+
if servers2[0].URL != expectedURL2 {
451+
t.Errorf("child2: expected project URL '%s', got '%s'", expectedURL2, servers2[0].URL)
452+
}
453+
454+
// Parent should still have its original project client
455+
parentServers := parent.projectClient.GetConfig().Servers
456+
expectedParent := fmt.Sprintf(DefaultProjectAPIURL, testutil.TestProjectSlug)
457+
if parentServers[0].URL != expectedParent {
458+
t.Errorf("parent: expected project URL '%s', got '%s'", expectedParent, parentServers[0].URL)
459+
}
460+
}
461+
462+
func TestOryClient_EnsureProjectClient_MissingSlugOnly(t *testing.T) {
463+
cfg := OryClientConfig{
464+
ProjectAPIKey: testutil.TestProjectAPIKey,
465+
// ProjectSlug is empty
466+
}
467+
468+
client, err := NewOryClient(cfg)
469+
if err != nil {
470+
t.Fatalf("unexpected error: %v", err)
471+
}
472+
473+
err = client.ensureProjectClient()
474+
if err == nil {
475+
t.Fatal("expected error when slug is missing")
476+
}
477+
if !contains(err.Error(), "project_slug and project_api_key are required") {
478+
t.Errorf("unexpected error message: %s", err.Error())
479+
}
480+
}
481+
482+
func TestOryClient_EnsureProjectClient_MissingKeyOnly(t *testing.T) {
483+
cfg := OryClientConfig{
484+
ProjectSlug: testutil.TestProjectSlug,
485+
// ProjectAPIKey is empty
486+
}
487+
488+
client, err := NewOryClient(cfg)
489+
if err != nil {
490+
t.Fatalf("unexpected error: %v", err)
491+
}
492+
493+
err = client.ensureProjectClient()
494+
if err == nil {
495+
t.Fatal("expected error when API key is missing")
496+
}
497+
if !contains(err.Error(), "project_slug and project_api_key are required") {
498+
t.Errorf("unexpected error message: %s", err.Error())
499+
}
500+
}
501+
317502
func TestIsRetryableError(t *testing.T) {
318503
tests := []struct {
319504
name string

internal/resources/oauth2client/resource.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ type OAuth2ClientResourceModel struct {
4343
ClientID types.String `tfsdk:"client_id"`
4444
ClientSecret types.String `tfsdk:"client_secret"`
4545
ClientName types.String `tfsdk:"client_name"`
46+
ProjectSlug types.String `tfsdk:"project_slug"`
47+
ProjectAPIKey types.String `tfsdk:"project_api_key"`
4648
GrantTypes types.List `tfsdk:"grant_types"`
4749
ResponseTypes types.List `tfsdk:"response_types"`
4850
Scope types.String `tfsdk:"scope"`
@@ -161,6 +163,15 @@ func (r *OAuth2ClientResource) Schema(ctx context.Context, req resource.SchemaRe
161163
stringplanmodifier.UseStateForUnknown(),
162164
},
163165
},
166+
"project_slug": schema.StringAttribute{
167+
Description: "Project slug for API access. Use this to pass credentials at the resource level when the provider is configured before the project exists (e.g., creating a project and OAuth2 client in the same apply). Overrides the provider-level project_slug.",
168+
Optional: true,
169+
},
170+
"project_api_key": schema.StringAttribute{
171+
Description: "Project API key for API access. Use this to pass credentials at the resource level when the provider is configured before the project exists (e.g., creating a project and OAuth2 client in the same apply). Overrides the provider-level project_api_key.",
172+
Optional: true,
173+
Sensitive: true,
174+
},
164175
"client_name": schema.StringAttribute{
165176
Description: "Human-readable name for the client.",
166177
Required: true,
@@ -356,6 +367,16 @@ func (r *OAuth2ClientResource) Schema(ctx context.Context, req resource.SchemaRe
356367
}
357368
}
358369

370+
// setResourceCredentials creates an isolated client with resource-level project credentials.
371+
// This enables creating OAuth2 clients in the same apply as the project they belong to.
372+
// The new client shares the console API client but has its own project API client,
373+
// avoiding race conditions when multiple resources use different credentials.
374+
func (r *OAuth2ClientResource) setResourceCredentials(slug, apiKey types.String) {
375+
if !slug.IsNull() && !slug.IsUnknown() && !apiKey.IsNull() && !apiKey.IsUnknown() {
376+
r.client = r.client.WithProjectCredentials(slug.ValueString(), apiKey.ValueString())
377+
}
378+
}
379+
359380
func (r *OAuth2ClientResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
360381
if req.ProviderData == nil {
361382
return
@@ -536,6 +557,9 @@ func (r *OAuth2ClientResource) Create(ctx context.Context, req resource.CreateRe
536557
oauthClient.BackchannelLogoutSessionRequired = ory.PtrBool(plan.BackchannelLogoutSessionRequired.ValueBool())
537558
}
538559

560+
// Set resource-level credentials if provided (enables same-apply with project creation)
561+
r.setResourceCredentials(plan.ProjectSlug, plan.ProjectAPIKey)
562+
539563
created, err := r.client.CreateOAuth2Client(ctx, oauthClient)
540564
if err != nil {
541565
resp.Diagnostics.AddError(
@@ -627,6 +651,9 @@ func (r *OAuth2ClientResource) Read(ctx context.Context, req resource.ReadReques
627651
return
628652
}
629653

654+
// Set resource-level credentials if provided
655+
r.setResourceCredentials(state.ProjectSlug, state.ProjectAPIKey)
656+
630657
oauthClient, err := r.client.GetOAuth2Client(ctx, state.ClientID.ValueString())
631658
if err != nil {
632659
resp.Diagnostics.AddError(
@@ -978,6 +1005,9 @@ func (r *OAuth2ClientResource) Update(ctx context.Context, req resource.UpdateRe
9781005
oauthClient.BackchannelLogoutSessionRequired = ory.PtrBool(plan.BackchannelLogoutSessionRequired.ValueBool())
9791006
}
9801007

1008+
// Set resource-level credentials if provided
1009+
r.setResourceCredentials(plan.ProjectSlug, plan.ProjectAPIKey)
1010+
9811011
updated, err := r.client.UpdateOAuth2Client(ctx, state.ClientID.ValueString(), oauthClient)
9821012
if err != nil {
9831013
resp.Diagnostics.AddError(
@@ -1063,6 +1093,9 @@ func (r *OAuth2ClientResource) Delete(ctx context.Context, req resource.DeleteRe
10631093
return
10641094
}
10651095

1096+
// Set resource-level credentials if provided
1097+
r.setResourceCredentials(state.ProjectSlug, state.ProjectAPIKey)
1098+
10661099
err := r.client.DeleteOAuth2Client(ctx, state.ClientID.ValueString())
10671100
if err != nil {
10681101
resp.Diagnostics.AddError(

internal/resources/oauth2client/resource_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,38 @@ func TestAccOAuth2ClientResource_withJWKS(t *testing.T) {
195195
})
196196
}
197197

198+
func TestAccOAuth2ClientResource_withResourceCredentials(t *testing.T) {
199+
project := acctest.GetTestProject(t)
200+
201+
acctest.RunTest(t, resource.TestCase{
202+
PreCheck: func() { acctest.AccPreCheck(t) },
203+
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories(),
204+
Steps: []resource.TestStep{
205+
// Create with resource-level project credentials
206+
{
207+
Config: acctest.LoadTestConfig(t, "testdata/with_resource_credentials.tf.tmpl", map[string]string{
208+
"Name": "Test Client Resource Creds",
209+
"ProjectSlug": project.Slug,
210+
"ProjectAPIKey": project.APIKey,
211+
}),
212+
Check: resource.ComposeAggregateTestCheckFunc(
213+
resource.TestCheckResourceAttrSet("ory_oauth2_client.test", "id"),
214+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "client_name", "Test Client Resource Creds"),
215+
resource.TestCheckResourceAttr("ory_oauth2_client.test", "project_slug", project.Slug),
216+
resource.TestCheckResourceAttrSet("ory_oauth2_client.test", "client_secret"),
217+
),
218+
},
219+
// ImportState — ignore resource-level credentials (not in API response)
220+
{
221+
ResourceName: "ory_oauth2_client.test",
222+
ImportState: true,
223+
ImportStateVerify: true,
224+
ImportStateVerifyIgnore: []string{"client_secret", "project_slug", "project_api_key"},
225+
},
226+
},
227+
})
228+
}
229+
198230
func TestAccOAuth2ClientResource_withTokenLifespans(t *testing.T) {
199231
acctest.RunTest(t, resource.TestCase{
200232
PreCheck: func() { acctest.AccPreCheck(t) },

0 commit comments

Comments
 (0)