Skip to content

Commit 00d5a84

Browse files
authored
Merge pull request #81 from ory/feat/apple-social-provider
feat: add Apple Sign-In support to ory_social_provider resource
2 parents cbee905 + 691429b commit 00d5a84

File tree

7 files changed

+321
-46
lines changed

7 files changed

+321
-46
lines changed

docs/resources/social_provider.md

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ The `provider_type` attribute determines which OAuth2/OIDC integration to use:
3333

3434
~> **Note:** When using `provider_type = "generic"`, you **must** set `issuer_url` to the OIDC issuer URL. The provider uses OIDC discovery to find authorization and token endpoints automatically.
3535

36+
## Apple Sign-In
37+
38+
Apple uses a non-standard authentication flow. Instead of a static `client_secret`, Apple requires:
39+
40+
- **`apple_team_id`** — Your Apple Developer Team ID (e.g., `KP76DQS54M`)
41+
- **`apple_private_key_id`** — The key ID from the Apple Developer portal (e.g., `UX56C66723`)
42+
- **`apple_private_key`** — The private key in PEM format (the contents of your `.p8` file)
43+
44+
Ory uses these to automatically generate the JWT `client_secret` required by Apple's OAuth2 flow. You do **not** need to set `client_secret` when using Apple-specific fields.
45+
46+
Alternatively, you may provide a pre-generated `client_secret` directly if you prefer to manage the JWT yourself.
47+
3648
## Example Usage
3749

3850
```terraform
@@ -64,13 +76,15 @@ resource "ory_social_provider" "microsoft" {
6476
scope = ["openid", "profile", "email"]
6577
}
6678
67-
# Apple Sign-In
79+
# Apple Sign-In (using Apple-specific credentials)
6880
resource "ory_social_provider" "apple" {
69-
provider_id = "apple"
70-
provider_type = "apple"
71-
client_id = var.apple_client_id
72-
client_secret = var.apple_client_secret
73-
scope = ["email", "name"]
81+
provider_id = "apple"
82+
provider_type = "apple"
83+
client_id = var.apple_service_id
84+
apple_team_id = var.apple_team_id
85+
apple_private_key_id = var.apple_private_key_id
86+
apple_private_key = var.apple_private_key
87+
scope = ["email", "name"]
7488
}
7589
7690
# Generic OIDC Provider with custom claims mapping
@@ -129,13 +143,25 @@ variable "azure_tenant_id" {
129143
type = string
130144
}
131145
132-
variable "apple_client_id" {
133-
type = string
146+
variable "apple_service_id" {
147+
description = "Apple Service ID (e.g., com.example.auth)"
148+
type = string
134149
}
135150
136-
variable "apple_client_secret" {
137-
type = string
138-
sensitive = true
151+
variable "apple_team_id" {
152+
description = "Apple Developer Team ID"
153+
type = string
154+
}
155+
156+
variable "apple_private_key_id" {
157+
description = "Apple private key ID from the Developer portal"
158+
type = string
159+
}
160+
161+
variable "apple_private_key" {
162+
description = "Apple private key in PEM format (.p8 file contents)"
163+
type = string
164+
sensitive = true
139165
}
140166
141167
variable "sso_client_id" {
@@ -173,6 +199,7 @@ If not set, the provider uses a default mapper that extracts the email claim.
173199
- **`provider_id` and `provider_type` cannot be changed** after creation. Changing either forces a new resource.
174200
- **`client_secret` is write-only.** The API does not return secrets on read, so Terraform cannot detect external changes to the secret.
175201
- **`tenant` maps to `microsoft_tenant`** in the Ory API. This is only used with `provider_type = "microsoft"`.
202+
- **Apple-specific fields** (`apple_team_id`, `apple_private_key_id`, `apple_private_key`) are only valid with `provider_type = "apple"`. The `apple_private_key` is write-only (not returned by API).
176203
- **Deleting the last provider** resets the entire OIDC configuration to a disabled state with an empty providers array.
177204

178205
## Import
@@ -183,21 +210,27 @@ Import using the provider ID:
183210
terraform import ory_social_provider.google google
184211
```
185212

186-
The `provider_id` is the unique identifier you chose when creating the provider. After import, you must provide `client_secret` in your configuration since it cannot be read from the API.
213+
The `provider_id` is the unique identifier you chose when creating the provider. After import, you must provide write-only credentials in your configuration since they cannot be read from the API:
214+
215+
- **Non-Apple providers:** Set `client_secret`.
216+
- **Apple providers:** Set either `client_secret` (pre-generated JWT) or all three Apple-specific fields (`apple_team_id`, `apple_private_key_id`, and `apple_private_key`).
187217

188218
<!-- schema generated by tfplugindocs -->
189219
## Schema
190220

191221
### Required
192222

193223
- `client_id` (String) OAuth2 client ID from the provider.
194-
- `client_secret` (String, Sensitive) OAuth2 client secret from the provider.
195224
- `provider_id` (String) Unique identifier for the provider (used in callback URLs).
196225
- `provider_type` (String) Provider type (google, github, microsoft, apple, generic, etc.).
197226

198227
### Optional
199228

229+
- `apple_private_key` (String, Sensitive) Apple private key in PEM format (contents of the .p8 file). Required when provider_type is "apple" and client_secret is not set. Ory uses this to generate the JWT client secret automatically.
230+
- `apple_private_key_id` (String) Apple private key ID from the Apple Developer portal (e.g., "UX56C66723"). Required when provider_type is "apple" and client_secret is not set.
231+
- `apple_team_id` (String) Apple Developer Team ID (e.g., "KP76DQS54M"). Required when provider_type is "apple" and client_secret is not set.
200232
- `auth_url` (String) Custom authorization URL (for non-standard providers).
233+
- `client_secret` (String, Sensitive) OAuth2 client secret from the provider. Required for all providers except Apple (where Ory generates the secret from apple_team_id, apple_private_key_id, and apple_private_key).
201234
- `issuer_url` (String) OIDC issuer URL (required for generic providers).
202235
- `mapper_url` (String) Jsonnet mapper URL for claims mapping. Can be a URL or base64-encoded Jsonnet (base64://...). If not set, a default mapper that extracts email from claims will be used.
203236
- `project_id` (String) Project ID. If not set, uses provider's project_id.

examples/resources/ory_social_provider/resource.tf

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ resource "ory_social_provider" "microsoft" {
2626
scope = ["openid", "profile", "email"]
2727
}
2828

29-
# Apple Sign-In
29+
# Apple Sign-In (using Apple-specific credentials)
3030
resource "ory_social_provider" "apple" {
31-
provider_id = "apple"
32-
provider_type = "apple"
33-
client_id = var.apple_client_id
34-
client_secret = var.apple_client_secret
35-
scope = ["email", "name"]
31+
provider_id = "apple"
32+
provider_type = "apple"
33+
client_id = var.apple_service_id
34+
apple_team_id = var.apple_team_id
35+
apple_private_key_id = var.apple_private_key_id
36+
apple_private_key = var.apple_private_key
37+
scope = ["email", "name"]
3638
}
3739

3840
# Generic OIDC Provider with custom claims mapping
@@ -91,13 +93,25 @@ variable "azure_tenant_id" {
9193
type = string
9294
}
9395

94-
variable "apple_client_id" {
95-
type = string
96+
variable "apple_service_id" {
97+
description = "Apple Service ID (e.g., com.example.auth)"
98+
type = string
9699
}
97100

98-
variable "apple_client_secret" {
99-
type = string
100-
sensitive = true
101+
variable "apple_team_id" {
102+
description = "Apple Developer Team ID"
103+
type = string
104+
}
105+
106+
variable "apple_private_key_id" {
107+
description = "Apple private key ID from the Developer portal"
108+
type = string
109+
}
110+
111+
variable "apple_private_key" {
112+
description = "Apple private key in PEM format (.p8 file contents)"
113+
type = string
114+
sensitive = true
101115
}
102116

103117
variable "sso_client_id" {

internal/resources/socialprovider/resource.go

Lines changed: 146 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ import (
1818
)
1919

2020
var (
21-
_ resource.Resource = &SocialProviderResource{}
22-
_ resource.ResourceWithConfigure = &SocialProviderResource{}
23-
_ resource.ResourceWithImportState = &SocialProviderResource{}
21+
_ resource.Resource = &SocialProviderResource{}
22+
_ resource.ResourceWithConfigure = &SocialProviderResource{}
23+
_ resource.ResourceWithImportState = &SocialProviderResource{}
24+
_ resource.ResourceWithValidateConfig = &SocialProviderResource{}
2425
)
2526

2627
func NewResource() resource.Resource {
@@ -32,18 +33,21 @@ type SocialProviderResource struct {
3233
}
3334

3435
type SocialProviderResourceModel struct {
35-
ID types.String `tfsdk:"id"`
36-
ProjectID types.String `tfsdk:"project_id"`
37-
ProviderID types.String `tfsdk:"provider_id"`
38-
ProviderType types.String `tfsdk:"provider_type"`
39-
ClientID types.String `tfsdk:"client_id"`
40-
ClientSecret types.String `tfsdk:"client_secret"`
41-
IssuerURL types.String `tfsdk:"issuer_url"`
42-
Scope types.List `tfsdk:"scope"`
43-
MapperURL types.String `tfsdk:"mapper_url"`
44-
AuthURL types.String `tfsdk:"auth_url"`
45-
TokenURL types.String `tfsdk:"token_url"`
46-
Tenant types.String `tfsdk:"tenant"`
36+
ID types.String `tfsdk:"id"`
37+
ProjectID types.String `tfsdk:"project_id"`
38+
ProviderID types.String `tfsdk:"provider_id"`
39+
ProviderType types.String `tfsdk:"provider_type"`
40+
ClientID types.String `tfsdk:"client_id"`
41+
ClientSecret types.String `tfsdk:"client_secret"`
42+
IssuerURL types.String `tfsdk:"issuer_url"`
43+
Scope types.List `tfsdk:"scope"`
44+
MapperURL types.String `tfsdk:"mapper_url"`
45+
AuthURL types.String `tfsdk:"auth_url"`
46+
TokenURL types.String `tfsdk:"token_url"`
47+
Tenant types.String `tfsdk:"tenant"`
48+
AppleTeamID types.String `tfsdk:"apple_team_id"`
49+
ApplePrivateKeyID types.String `tfsdk:"apple_private_key_id"`
50+
ApplePrivateKey types.String `tfsdk:"apple_private_key"`
4751
}
4852

4953
func (r *SocialProviderResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
@@ -89,8 +93,8 @@ func (r *SocialProviderResource) Schema(ctx context.Context, req resource.Schema
8993
Required: true,
9094
},
9195
"client_secret": schema.StringAttribute{
92-
Description: "OAuth2 client secret from the provider.",
93-
Required: true,
96+
Description: "OAuth2 client secret from the provider. Required for all providers except Apple (where Ory generates the secret from apple_team_id, apple_private_key_id, and apple_private_key).",
97+
Optional: true,
9498
Sensitive: true,
9599
},
96100
"issuer_url": schema.StringAttribute{
@@ -118,6 +122,19 @@ func (r *SocialProviderResource) Schema(ctx context.Context, req resource.Schema
118122
Description: "Tenant ID (for Microsoft/Azure providers).",
119123
Optional: true,
120124
},
125+
"apple_team_id": schema.StringAttribute{
126+
Description: "Apple Developer Team ID (e.g., \"KP76DQS54M\"). Required when provider_type is \"apple\" and client_secret is not set.",
127+
Optional: true,
128+
},
129+
"apple_private_key_id": schema.StringAttribute{
130+
Description: "Apple private key ID from the Apple Developer portal (e.g., \"UX56C66723\"). Required when provider_type is \"apple\" and client_secret is not set.",
131+
Optional: true,
132+
},
133+
"apple_private_key": schema.StringAttribute{
134+
Description: "Apple private key in PEM format (contents of the .p8 file). Required when provider_type is \"apple\" and client_secret is not set. Ory uses this to generate the JWT client secret automatically.",
135+
Optional: true,
136+
Sensitive: true,
137+
},
121138
},
122139
}
123140
}
@@ -135,19 +152,103 @@ func (r *SocialProviderResource) Configure(ctx context.Context, req resource.Con
135152
r.client = oryClient
136153
}
137154

155+
func (r *SocialProviderResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
156+
var config SocialProviderResourceModel
157+
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
158+
if resp.Diagnostics.HasError() {
159+
return
160+
}
161+
162+
// Ensure provider_type is specified and known, since it is required to build the API payload.
163+
if config.ProviderType.IsNull() {
164+
resp.Diagnostics.AddAttributeError(
165+
path.Root("provider_type"),
166+
"Missing Required Attribute",
167+
"provider_type must be specified.",
168+
)
169+
return
170+
}
171+
if config.ProviderType.IsUnknown() {
172+
// Cannot validate attribute combinations when provider_type is unknown.
173+
return
174+
}
175+
176+
providerType := config.ProviderType.ValueString()
177+
178+
// Treat attributes as "configured" only when they are both non-null and non-unknown.
179+
// This ensures validation aligns with buildProviderConfig, which drops unknown values.
180+
hasClientSecret := !config.ClientSecret.IsNull() && !config.ClientSecret.IsUnknown()
181+
hasAppleTeamID := !config.AppleTeamID.IsNull() && !config.AppleTeamID.IsUnknown()
182+
hasApplePrivateKeyID := !config.ApplePrivateKeyID.IsNull() && !config.ApplePrivateKeyID.IsUnknown()
183+
hasApplePrivateKey := !config.ApplePrivateKey.IsNull() && !config.ApplePrivateKey.IsUnknown()
184+
hasAnyAppleField := hasAppleTeamID || hasApplePrivateKeyID || hasApplePrivateKey
185+
hasAllAppleFields := hasAppleTeamID && hasApplePrivateKeyID && hasApplePrivateKey
186+
187+
// Validate that known values are not empty strings
188+
if hasClientSecret && config.ClientSecret.ValueString() == "" {
189+
resp.Diagnostics.AddAttributeError(path.Root("client_secret"), "Invalid Attribute Value", "client_secret must not be an empty string.")
190+
}
191+
if hasAppleTeamID && config.AppleTeamID.ValueString() == "" {
192+
resp.Diagnostics.AddAttributeError(path.Root("apple_team_id"), "Invalid Attribute Value", "apple_team_id must not be an empty string.")
193+
}
194+
if hasApplePrivateKeyID && config.ApplePrivateKeyID.ValueString() == "" {
195+
resp.Diagnostics.AddAttributeError(path.Root("apple_private_key_id"), "Invalid Attribute Value", "apple_private_key_id must not be an empty string.")
196+
}
197+
if hasApplePrivateKey && config.ApplePrivateKey.ValueString() == "" {
198+
resp.Diagnostics.AddAttributeError(path.Root("apple_private_key"), "Invalid Attribute Value", "apple_private_key must not be an empty string.")
199+
}
200+
201+
if providerType == "apple" {
202+
// Apple: needs either client_secret OR all three Apple-specific fields
203+
if !hasClientSecret && !hasAllAppleFields {
204+
resp.Diagnostics.AddAttributeError(
205+
path.Root("client_secret"),
206+
"Missing Apple Provider Configuration",
207+
"Apple providers require either client_secret or all three Apple-specific attributes: apple_team_id, apple_private_key_id, and apple_private_key.",
208+
)
209+
}
210+
if hasAnyAppleField && !hasAllAppleFields {
211+
resp.Diagnostics.AddAttributeError(
212+
path.Root("apple_team_id"),
213+
"Incomplete Apple Provider Configuration",
214+
"When using Apple-specific attributes, all three must be set: apple_team_id, apple_private_key_id, and apple_private_key.",
215+
)
216+
}
217+
} else {
218+
// Non-Apple: client_secret is required
219+
if !hasClientSecret {
220+
resp.Diagnostics.AddAttributeError(
221+
path.Root("client_secret"),
222+
"Missing Required Attribute",
223+
fmt.Sprintf("client_secret is required for provider_type %q.", providerType),
224+
)
225+
}
226+
// Non-Apple: Apple-specific fields should not be set
227+
if hasAnyAppleField {
228+
resp.Diagnostics.AddAttributeError(
229+
path.Root("provider_type"),
230+
"Invalid Attribute Combination",
231+
"apple_team_id, apple_private_key_id, and apple_private_key are only valid when provider_type is \"apple\".",
232+
)
233+
}
234+
}
235+
}
236+
138237
// defaultMapperURL returns the default Jsonnet mapper for common providers.
139238
// This is a simple mapper that extracts email and subject from the claims.
140239
// The base64-encoded Jsonnet maps claims to identity traits.
141240
const defaultMapperURL = "base64://bG9jYWwgY2xhaW1zID0gc3RkLmV4dFZhcignY2xhaW1zJyk7CnsKICBpZGVudGl0eTogewogICAgdHJhaXRzOiB7CiAgICAgIGVtYWlsOiBjbGFpbXMuZW1haWwsCiAgICB9LAogIH0sCn0="
142241

143242
func (r *SocialProviderResource) buildProviderConfig(ctx context.Context, plan *SocialProviderResourceModel) map[string]interface{} {
144243
config := map[string]interface{}{
145-
"id": plan.ProviderID.ValueString(),
146-
"provider": plan.ProviderType.ValueString(),
147-
"client_id": plan.ClientID.ValueString(),
148-
"client_secret": plan.ClientSecret.ValueString(),
244+
"id": plan.ProviderID.ValueString(),
245+
"provider": plan.ProviderType.ValueString(),
246+
"client_id": plan.ClientID.ValueString(),
149247
}
150248

249+
if !plan.ClientSecret.IsNull() && !plan.ClientSecret.IsUnknown() && plan.ClientSecret.ValueString() != "" {
250+
config["client_secret"] = plan.ClientSecret.ValueString()
251+
}
151252
if !plan.IssuerURL.IsNull() && !plan.IssuerURL.IsUnknown() {
152253
config["issuer_url"] = plan.IssuerURL.ValueString()
153254
}
@@ -172,6 +273,17 @@ func (r *SocialProviderResource) buildProviderConfig(ctx context.Context, plan *
172273
config["microsoft_tenant"] = plan.Tenant.ValueString()
173274
}
174275

276+
// Apple-specific fields — skip empty strings to avoid sending blank credentials
277+
if !plan.AppleTeamID.IsNull() && !plan.AppleTeamID.IsUnknown() && plan.AppleTeamID.ValueString() != "" {
278+
config["apple_team_id"] = plan.AppleTeamID.ValueString()
279+
}
280+
if !plan.ApplePrivateKeyID.IsNull() && !plan.ApplePrivateKeyID.IsUnknown() && plan.ApplePrivateKeyID.ValueString() != "" {
281+
config["apple_private_key_id"] = plan.ApplePrivateKeyID.ValueString()
282+
}
283+
if !plan.ApplePrivateKey.IsNull() && !plan.ApplePrivateKey.IsUnknown() && plan.ApplePrivateKey.ValueString() != "" {
284+
config["apple_private_key"] = plan.ApplePrivateKey.ValueString()
285+
}
286+
175287
return config
176288
}
177289

@@ -429,6 +541,19 @@ func (r *SocialProviderResource) Read(ctx context.Context, req resource.ReadRequ
429541
state.Tenant = types.StringValue(tenant)
430542
}
431543

544+
// Read Apple-specific fields, clearing stale state when not returned by the API
545+
if teamID, ok := provider["apple_team_id"].(string); ok && teamID != "" {
546+
state.AppleTeamID = types.StringValue(teamID)
547+
} else {
548+
state.AppleTeamID = types.StringNull()
549+
}
550+
if keyID, ok := provider["apple_private_key_id"].(string); ok && keyID != "" {
551+
state.ApplePrivateKeyID = types.StringValue(keyID)
552+
} else {
553+
state.ApplePrivateKeyID = types.StringNull()
554+
}
555+
// Don't read back apple_private_key for security - it's sensitive
556+
432557
// Always ensure ID and ProjectID are set in state
433558
state.ID = types.StringValue(providerID)
434559
state.ProjectID = types.StringValue(projectID)

0 commit comments

Comments
 (0)