Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
41 changes: 41 additions & 0 deletions docs/resources/oauth2_client.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ resource "ory_oauth2_client" "cli_tool" {
scope = "openid offline_access"
}

# Same-apply: Create project and OAuth2 client together
# Use resource-level credentials when the project doesn't exist yet
resource "ory_oauth2_client" "same_apply" {
project_slug = ory_project.main.slug
project_api_key = ory_project_api_key.main.value

client_name = "Created with Project"
grant_types = ["client_credentials"]
token_endpoint_auth_method = "client_secret_post"
scope = "api:read api:write"
}

output "api_service_client_id" {
value = ory_oauth2_client.api_service.client_id
}
Expand Down Expand Up @@ -262,6 +274,33 @@ The provider supports both OIDC front-channel and back-channel logout:
- `frontchannel_logout_session_required` — Whether the client requires a session identifier (`sid`) in front-channel logout notifications.
- `backchannel_logout_session_required` — Whether the client requires a session identifier (`sid`) in back-channel logout notifications.

## Resource-Level Credentials (Same-Apply with Project Creation)

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:

```hcl
resource "ory_project" "main" {
name = "my-project"
environment = "prod"
}

resource "ory_project_api_key" "main" {
project_id = ory_project.main.id
name = "terraform-key"
}

resource "ory_oauth2_client" "api" {
project_slug = ory_project.main.slug
project_api_key = ory_project_api_key.main.value

client_name = "API Client"
grant_types = ["client_credentials"]
scope = "read write"
}
```

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.

## Import

OAuth2 clients can be imported using their client ID:
Expand Down Expand Up @@ -307,6 +346,8 @@ terraform import ory_oauth2_client.api <client-id>
- `metadata` (String) Custom metadata as JSON string.
- `policy_uri` (String) URL of the client's privacy policy.
- `post_logout_redirect_uris` (List of String) List of allowed post-logout redirect URIs for OpenID Connect logout.
- `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.
- `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.
- `redirect_uris` (List of String) List of allowed redirect URIs for authorization code flow.
- `refresh_token_grant_access_token_lifespan` (String) Access token lifespan for refresh token grant (e.g., '1h', '30m').
- `refresh_token_grant_id_token_lifespan` (String) ID token lifespan for refresh token grant (e.g., '1h', '30m').
Expand Down
12 changes: 12 additions & 0 deletions examples/resources/ory_oauth2_client/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ resource "ory_oauth2_client" "cli_tool" {
scope = "openid offline_access"
}

# Same-apply: Create project and OAuth2 client together
# Use resource-level credentials when the project doesn't exist yet
resource "ory_oauth2_client" "same_apply" {
project_slug = ory_project.main.slug
project_api_key = ory_project_api_key.main.value

client_name = "Created with Project"
grant_types = ["client_credentials"]
token_endpoint_auth_method = "client_secret_post"
scope = "api:read api:write"
}
Comment on lines +124 to +134
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example references ory_project.main and ory_project_api_key.main resources that are not defined in this file. For a complete, working example, these resource definitions should be included. Consider adding:

resource "ory_project" "main" {
  name        = "my-project"
  environment = "prod"
}

resource "ory_project_api_key" "main" {
  project_id = ory_project.main.id
  name       = "terraform-key"
}

before the ory_oauth2_client resource, or add a comment noting that these resources need to be defined elsewhere.

Copilot uses AI. Check for mistakes.

output "api_service_client_id" {
value = ory_oauth2_client.api_service.client_id
}
Expand Down
133 changes: 130 additions & 3 deletions internal/client/client.go

Large diffs are not rendered by default.

166 changes: 166 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,172 @@ func TestIsRateLimitError(t *testing.T) {
}
}

func TestOryClient_EnsureProjectClient_NilWithoutCredentials(t *testing.T) {
cfg := OryClientConfig{
WorkspaceAPIKey: testutil.TestWorkspaceAPIKey,
ConsoleAPIURL: DefaultConsoleAPIURL,
// No ProjectAPIKey or ProjectSlug
}

client, err := NewOryClient(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if client.projectClient != nil {
t.Error("project client should be nil without credentials")
}

err = client.ensureProjectClient()
if err == nil {
t.Fatal("expected error from ensureProjectClient without credentials")
}
if !contains(err.Error(), "project_slug and project_api_key are required") {
t.Errorf("unexpected error message: %s", err.Error())
}
}

func TestOryClient_EnsureProjectClient_LazyInit(t *testing.T) {
cfg := OryClientConfig{
ProjectAPIKey: testutil.TestProjectAPIKey,
ProjectSlug: testutil.TestProjectSlug,
}

client, err := NewOryClient(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Project client should already be initialized since credentials were provided at creation
if client.projectClient == nil {
t.Error("project client should be initialized when credentials provided at creation")
}

// ensureProjectClient should succeed (client already initialized)
if err := client.ensureProjectClient(); err != nil {
t.Errorf("unexpected error: %v", err)
}
}

func TestOryClient_SetProjectCredentials(t *testing.T) {
cfg := OryClientConfig{
WorkspaceAPIKey: testutil.TestWorkspaceAPIKey,
ConsoleAPIURL: DefaultConsoleAPIURL,
// No project credentials initially
}

client, err := NewOryClient(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Verify project client is nil initially
if client.projectClient != nil {
t.Error("project client should be nil without credentials")
}

// Set credentials dynamically
client.SetProjectCredentials(testutil.TestProjectSlug, testutil.TestProjectAPIKey)

// Verify credentials were updated
if client.config.ProjectSlug != testutil.TestProjectSlug {
t.Errorf("expected slug '%s', got '%s'", testutil.TestProjectSlug, client.config.ProjectSlug)
}
if client.config.ProjectAPIKey != testutil.TestProjectAPIKey {
t.Errorf("expected API key '%s', got '%s'", testutil.TestProjectAPIKey, client.config.ProjectAPIKey)
}

// ensureProjectClient should now succeed and initialize the client
if err := client.ensureProjectClient(); err != nil {
t.Fatalf("unexpected error after setting credentials: %v", err)
}
if client.projectClient == nil {
t.Error("project client should be initialized after setting credentials")
}

// Verify the project client is configured with the correct URL
servers := client.projectClient.GetConfig().Servers
expectedURL := fmt.Sprintf(DefaultProjectAPIURL, testutil.TestProjectSlug)
if servers[0].URL != expectedURL {
t.Errorf("expected project URL '%s', got '%s'", expectedURL, servers[0].URL)
}
}

func TestOryClient_SetProjectCredentials_ReInitializes(t *testing.T) {
cfg := OryClientConfig{
ProjectAPIKey: testutil.TestProjectAPIKey,
ProjectSlug: testutil.TestProjectSlug,
}

client, err := NewOryClient(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

originalClient := client.projectClient
if originalClient == nil {
t.Fatal("project client should be initialized")
}

// Set new credentials — should clear the existing client
client.SetProjectCredentials("new-slug", "new-key")

if client.projectClient != nil {
t.Error("project client should be nil after SetProjectCredentials (pending re-init)")
}

// ensureProjectClient should create a new client with the new slug
if err := client.ensureProjectClient(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

servers := client.projectClient.GetConfig().Servers
expectedURL := fmt.Sprintf(DefaultProjectAPIURL, "new-slug")
if servers[0].URL != expectedURL {
t.Errorf("expected new project URL '%s', got '%s'", expectedURL, servers[0].URL)
}
}

func TestOryClient_EnsureProjectClient_MissingSlugOnly(t *testing.T) {
cfg := OryClientConfig{
ProjectAPIKey: testutil.TestProjectAPIKey,
// ProjectSlug is empty
}

client, err := NewOryClient(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

err = client.ensureProjectClient()
if err == nil {
t.Fatal("expected error when slug is missing")
}
if !contains(err.Error(), "project_slug and project_api_key are required") {
t.Errorf("unexpected error message: %s", err.Error())
}
}

func TestOryClient_EnsureProjectClient_MissingKeyOnly(t *testing.T) {
cfg := OryClientConfig{
ProjectSlug: testutil.TestProjectSlug,
// ProjectAPIKey is empty
}

client, err := NewOryClient(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

err = client.ensureProjectClient()
if err == nil {
t.Fatal("expected error when API key is missing")
}
if !contains(err.Error(), "project_slug and project_api_key are required") {
t.Errorf("unexpected error message: %s", err.Error())
}
}

func TestIsRetryableError(t *testing.T) {
tests := []struct {
name string
Expand Down
31 changes: 31 additions & 0 deletions internal/resources/oauth2client/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ type OAuth2ClientResourceModel struct {
ClientID types.String `tfsdk:"client_id"`
ClientSecret types.String `tfsdk:"client_secret"`
ClientName types.String `tfsdk:"client_name"`
ProjectSlug types.String `tfsdk:"project_slug"`
ProjectAPIKey types.String `tfsdk:"project_api_key"`
GrantTypes types.List `tfsdk:"grant_types"`
ResponseTypes types.List `tfsdk:"response_types"`
Scope types.String `tfsdk:"scope"`
Expand Down Expand Up @@ -161,6 +163,15 @@ func (r *OAuth2ClientResource) Schema(ctx context.Context, req resource.SchemaRe
stringplanmodifier.UseStateForUnknown(),
},
},
"project_slug": schema.StringAttribute{
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.",
Optional: true,
},
"project_api_key": schema.StringAttribute{
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.",
Optional: true,
Sensitive: true,
},
"client_name": schema.StringAttribute{
Description: "Human-readable name for the client.",
Required: true,
Expand Down Expand Up @@ -356,6 +367,14 @@ func (r *OAuth2ClientResource) Schema(ctx context.Context, req resource.SchemaRe
}
}

// setResourceCredentials sets project credentials from resource-level attributes if provided.
// This enables creating OAuth2 clients in the same apply as the project they belong to.
func (r *OAuth2ClientResource) setResourceCredentials(slug, apiKey types.String) {
if !slug.IsNull() && !slug.IsUnknown() && !apiKey.IsNull() && !apiKey.IsUnknown() {
r.client.SetProjectCredentials(slug.ValueString(), apiKey.ValueString())
}
}

func (r *OAuth2ClientResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
Expand Down Expand Up @@ -536,6 +555,9 @@ func (r *OAuth2ClientResource) Create(ctx context.Context, req resource.CreateRe
oauthClient.BackchannelLogoutSessionRequired = ory.PtrBool(plan.BackchannelLogoutSessionRequired.ValueBool())
}

// Set resource-level credentials if provided (enables same-apply with project creation)
r.setResourceCredentials(plan.ProjectSlug, plan.ProjectAPIKey)

created, err := r.client.CreateOAuth2Client(ctx, oauthClient)
if err != nil {
resp.Diagnostics.AddError(
Expand Down Expand Up @@ -627,6 +649,9 @@ func (r *OAuth2ClientResource) Read(ctx context.Context, req resource.ReadReques
return
}

// Set resource-level credentials if provided
r.setResourceCredentials(state.ProjectSlug, state.ProjectAPIKey)

oauthClient, err := r.client.GetOAuth2Client(ctx, state.ClientID.ValueString())
if err != nil {
resp.Diagnostics.AddError(
Expand Down Expand Up @@ -978,6 +1003,9 @@ func (r *OAuth2ClientResource) Update(ctx context.Context, req resource.UpdateRe
oauthClient.BackchannelLogoutSessionRequired = ory.PtrBool(plan.BackchannelLogoutSessionRequired.ValueBool())
}

// Set resource-level credentials if provided
r.setResourceCredentials(plan.ProjectSlug, plan.ProjectAPIKey)

updated, err := r.client.UpdateOAuth2Client(ctx, state.ClientID.ValueString(), oauthClient)
if err != nil {
resp.Diagnostics.AddError(
Expand Down Expand Up @@ -1063,6 +1091,9 @@ func (r *OAuth2ClientResource) Delete(ctx context.Context, req resource.DeleteRe
return
}

// Set resource-level credentials if provided
r.setResourceCredentials(state.ProjectSlug, state.ProjectAPIKey)

err := r.client.DeleteOAuth2Client(ctx, state.ClientID.ValueString())
if err != nil {
resp.Diagnostics.AddError(
Expand Down
32 changes: 32 additions & 0 deletions internal/resources/oauth2client/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,38 @@ func TestAccOAuth2ClientResource_withJWKS(t *testing.T) {
})
}

func TestAccOAuth2ClientResource_withResourceCredentials(t *testing.T) {
project := acctest.GetTestProject(t)

acctest.RunTest(t, resource.TestCase{
PreCheck: func() { acctest.AccPreCheck(t) },
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories(),
Steps: []resource.TestStep{
// Create with resource-level project credentials
{
Config: acctest.LoadTestConfig(t, "testdata/with_resource_credentials.tf.tmpl", map[string]string{
"Name": "Test Client Resource Creds",
"ProjectSlug": project.Slug,
"ProjectAPIKey": project.APIKey,
}),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("ory_oauth2_client.test", "id"),
resource.TestCheckResourceAttr("ory_oauth2_client.test", "client_name", "Test Client Resource Creds"),
resource.TestCheckResourceAttr("ory_oauth2_client.test", "project_slug", project.Slug),
resource.TestCheckResourceAttrSet("ory_oauth2_client.test", "client_secret"),
),
},
// ImportState — ignore resource-level credentials (not in API response)
{
ResourceName: "ory_oauth2_client.test",
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"client_secret", "project_slug", "project_api_key"},
},
},
})
}

func TestAccOAuth2ClientResource_withTokenLifespans(t *testing.T) {
acctest.RunTest(t, resource.TestCase{
PreCheck: func() { acctest.AccPreCheck(t) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
resource "ory_oauth2_client" "test" {
project_slug = "[[ .ProjectSlug ]]"
project_api_key = "[[ .ProjectAPIKey ]]"

client_name = "[[ .Name ]]"

grant_types = ["client_credentials"]
response_types = ["token"]
scope = "api:read"
}
Loading