Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
21 changes: 19 additions & 2 deletions docs/data-sources/identity_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This data source retrieves a specific identity schema from the project, allowing

~> **Note:** Ory may assign hash-based IDs to schemas. Use the `ory_identity_schemas` (plural) data source to discover available schema IDs, or use the `id` output from an `ory_identity_schema` resource.

~> **Tip:** Set `project_id` to look up schemas via the console API (workspace key only). This is useful during project bootstrap when `project_slug` and `project_api_key` are not yet available.
~> **Tip:** Set `project_id` when only a workspace API key is available (e.g., during project bootstrap before `project_slug` and `project_api_key` exist). When project credentials are configured, the Kratos API is preferred automatically as it returns canonical hash-based IDs with full schema content.

## Example Usage

Expand Down Expand Up @@ -66,6 +66,23 @@ data "ory_identity_schema" "bootstrap" {
id = "preset://username"
project_id = "your-project-uuid"
}

# Create a new project and reuse an existing workspace schema as default
resource "ory_project" "new" {
name = "my-new-project"
}

data "ory_identity_schema" "existing" {
id = "670f71...full-hash-id"
project_id = ory_project.new.id
}

resource "ory_identity_schema" "default" {
schema_id = "customer"
project_id = ory_project.new.id
schema = data.ory_identity_schema.existing.schema
set_default = true
}
```

<!-- schema generated by tfplugindocs -->
Expand All @@ -77,7 +94,7 @@ data "ory_identity_schema" "bootstrap" {

### Optional

- `project_id` (String) The ID of the project to look up schemas from. If not set, uses the provider's project_id. When set, schemas are read from the project config via the console API (workspace key), which does not require project_slug or project_api_key.
- `project_id` (String) The ID of the project. If not set, uses the provider's project_id. The Kratos API is preferred when project_slug and project_api_key are configured (returns canonical hash IDs with full schema content). When only a workspace key is available, schemas are read from the project config via the console API.

### Read-Only

Expand Down
2 changes: 1 addition & 1 deletion docs/data-sources/identity_schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ output "schemas" {

### Optional

- `project_id` (String) The ID of the project to list schemas from. If not set, uses the provider's project_id. When set, schemas are read from the project config via the console API (workspace key), which does not require project_slug or project_api_key.
- `project_id` (String) The ID of the project to list schemas from. If not set, uses the provider's project_id. The Kratos API is preferred when project_slug and project_api_key are configured (returns canonical hash IDs with full schema content). When only a workspace key is available, schemas are read from the project config via the console API.

### Read-Only

Expand Down
28 changes: 28 additions & 0 deletions docs/resources/social_provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ resource "ory_social_provider" "google" {
scope = ["email", "profile"]
}

# Generic OIDC with a custom base redirect URI (e.g., when using a custom domain)
resource "ory_social_provider" "corporate_sso_custom_domain" {
provider_id = "corporate-sso-custom-domain"
provider_type = "generic"
client_id = var.sso_client_id
client_secret = var.sso_client_secret
issuer_url = "https://sso.example.com"
scope = ["openid", "profile", "email"]
base_redirect_uri = "https://iam.example.com"
}

# GitHub
resource "ory_social_provider" "github" {
provider_id = "github"
Expand Down Expand Up @@ -194,6 +205,22 @@ If not set, the provider uses a default mapper that extracts the email claim.

~> **Note:** The `mapper_url` value may be transformed by the API (e.g., stored as a GCS URL). The provider only tracks this field if you explicitly set it in your configuration to avoid false drift detection.

## Base Redirect URI

The `base_redirect_uri` attribute overrides the base URL Ory uses when constructing OIDC callback URLs. Use this when your project is accessible under a custom domain and you want callbacks to go to that domain rather than the default Ory project URL.

```hcl
resource "ory_social_provider" "google" {
provider_id = "google"
provider_type = "google"
client_id = var.google_client_id
client_secret = var.google_client_secret
base_redirect_uri = "https://iam.example.com"
}
```

~> **Note:** `base_redirect_uri` is a **global** OIDC configuration setting, not per-provider. If you have multiple `ory_social_provider` resources and set `base_redirect_uri` in more than one, the last applied value will take effect for all providers.

## Important Behaviors

- **`provider_id` and `provider_type` cannot be changed** after creation. Changing either forces a new resource.
Expand Down Expand Up @@ -230,6 +257,7 @@ The `provider_id` is the unique identifier you chose when creating the provider.
- `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.
- `apple_team_id` (String) Apple Developer Team ID (e.g., "KP76DQS54M"). Required when provider_type is "apple" and client_secret is not set.
- `auth_url` (String) Custom authorization URL (for non-standard providers).
- `base_redirect_uri` (String) Override the base redirect URI for OIDC callbacks (e.g., "https://iam.example.com"). When set, Ory constructs callback URLs using this base instead of the default project domain. This is a global OIDC config setting — if multiple social providers set different values, the last applied value wins.
- `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).
- `issuer_url` (String) OIDC issuer URL (required for generic providers).
- `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.
Expand Down
17 changes: 17 additions & 0 deletions examples/data-sources/ory_identity_schema/data-source.tf
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,20 @@ data "ory_identity_schema" "bootstrap" {
id = "preset://username"
project_id = "your-project-uuid"
}

# Create a new project and reuse an existing workspace schema as default
resource "ory_project" "new" {
name = "my-new-project"
}

data "ory_identity_schema" "existing" {
id = "670f71...full-hash-id"
project_id = ory_project.new.id
}

resource "ory_identity_schema" "default" {
schema_id = "customer"
project_id = ory_project.new.id
schema = data.ory_identity_schema.existing.schema
set_default = true
}
11 changes: 11 additions & 0 deletions examples/resources/ory_social_provider/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ resource "ory_social_provider" "google" {
scope = ["email", "profile"]
}

# Generic OIDC with a custom base redirect URI (e.g., when using a custom domain)
resource "ory_social_provider" "corporate_sso_custom_domain" {
provider_id = "corporate-sso-custom-domain"
provider_type = "generic"
client_id = var.sso_client_id
client_secret = var.sso_client_secret
issuer_url = "https://sso.example.com"
scope = ["openid", "profile", "email"]
base_redirect_uri = "https://iam.example.com"
}

# GitHub
resource "ory_social_provider" "github" {
provider_id = "github"
Expand Down
127 changes: 121 additions & 6 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"sync"
Expand Down Expand Up @@ -1386,14 +1388,15 @@ func (c *OryClient) ListIdentitySchemasViaProject(ctx context.Context, projectID
if err != nil {
return nil, fmt.Errorf("getting project for schema lookup: %w", err)
}
return extractSchemasFromProjectConfig(project)
return extractSchemasFromProjectConfig(ctx, project)
}

// extractSchemasFromProjectConfig reads the identity schemas array from the
// project's kratos config and converts each entry into an
// IdentitySchemaContainer. For base64-encoded schemas the content is decoded
// inline; preset schemas are returned with an empty schema body.
func extractSchemasFromProjectConfig(project *ory.Project) ([]ory.IdentitySchemaContainer, error) {
// inline; for HTTPS URLs the content is fetched over HTTP; preset schemas
// are returned with an empty schema body.
func extractSchemasFromProjectConfig(ctx context.Context, project *ory.Project) ([]ory.IdentitySchemaContainer, error) {
if project.Services.Identity == nil {
return nil, nil
}
Expand All @@ -1415,7 +1418,8 @@ func extractSchemasFromProjectConfig(project *ory.Project) ([]ory.IdentitySchema

container := ory.IdentitySchemaContainer{Id: id}

if strings.HasPrefix(rawURL, "base64://") {
switch {
case strings.HasPrefix(rawURL, "base64://"):
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(rawURL, "base64://"))
if err != nil {
return nil, fmt.Errorf("decoding base64 schema %q: %w", id, err)
Expand All @@ -1425,8 +1429,21 @@ func extractSchemasFromProjectConfig(project *ory.Project) ([]ory.IdentitySchema
return nil, fmt.Errorf("parsing JSON for schema %q: %w", id, err)
}
container.Schema = schemaObj
} else {
// Preset or URL-based schemas: return an empty object so

case strings.HasPrefix(rawURL, schemeHTTPS+"://"):
// The Ory API transforms base64 schema URLs to HTTPS URLs after
// processing. Fetch the actual schema content from the URL.
schemaObj, err := fetchSchemaFromURL(ctx, rawURL)
if err != nil {
// Non-fatal: return empty body rather than failing the entire list.
// The data source can detect empty bodies and attempt fallback.
container.Schema = map[string]interface{}{}
} else {
container.Schema = schemaObj
}

default:
// Preset or unrecognized URL schemes: return an empty object so
// json.Marshal produces "{}" instead of "null".
container.Schema = map[string]interface{}{}
}
Expand All @@ -1436,6 +1453,104 @@ func extractSchemasFromProjectConfig(project *ory.Project) ([]ory.IdentitySchema
return result, nil
}

// schemaFetchClient is a dedicated HTTP client for fetching schema content from
// trusted URLs returned by the Ory API. It has a short timeout and allows at
// most one HTTPS redirect to minimize SSRF risk.
var schemaFetchClient = &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 2 {
return fmt.Errorf("too many redirects fetching schema")
}
if req.URL.Scheme != "https" {
return fmt.Errorf("refusing non-HTTPS redirect for schema URL")
Comment on lines +1510 to +1513
}
return nil
},
}

// hostChecker is the function used to check whether a host is private.
// It is a variable so tests can override it.
var hostChecker = isPrivateHost

// fetchSchemaFromURL retrieves a JSON schema from an HTTPS URL. The URL must
// use the https scheme (enforced by the caller's switch statement) and must not
// resolve to a private/loopback address.
func fetchSchemaFromURL(ctx context.Context, schemaURL string) (map[string]interface{}, error) {
parsed, err := url.Parse(schemaURL)
if err != nil {
return nil, fmt.Errorf("parsing schema URL %q: %w", schemaURL, err)
}
if parsed.Scheme != "https" {
return nil, fmt.Errorf("refusing non-HTTPS schema URL %q", schemaURL)
}
host := parsed.Hostname()
if hostChecker(host) {
return nil, fmt.Errorf("refusing schema URL with private/loopback host %q", host)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, schemaURL, nil) // #nosec G107 -- URL comes from Ory API project config
if err != nil {
return nil, fmt.Errorf("creating request for schema %q: %w", schemaURL, err)
}

resp, err := schemaFetchClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching schema from %q: %w", schemaURL, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching schema from %q: HTTP %d", schemaURL, resp.StatusCode)
}

body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
if err != nil {
return nil, fmt.Errorf("reading schema from %q: %w", schemaURL, err)
}

var schemaObj map[string]interface{}
if err := json.Unmarshal(body, &schemaObj); err != nil {
return nil, fmt.Errorf("parsing schema JSON from %q: %w", schemaURL, err)
}
return schemaObj, nil
}

// isPrivateHost returns true if the host is a loopback, private, or link-local
// address. For DNS names it resolves the host and checks all resulting IPs to
// prevent DNS rebinding attacks.
func isPrivateHost(host string) bool {
if host == "localhost" {
return true
}

// Try parsing as an IP literal first.
if addr, err := netip.ParseAddr(host); err == nil {
return isPrivateAddr(addr)
}

// It's a DNS name — resolve and check all A/AAAA records.
resolver := &net.Resolver{}
addrs, err := resolver.LookupHost(context.Background(), host)
if err != nil {
// DNS resolution failed — block to be safe.
return true
}
for _, a := range addrs {
if addr, err := netip.ParseAddr(a); err == nil && isPrivateAddr(addr) {
return true
}
}
return false
}

// isPrivateAddr checks whether an IP address is loopback, private, link-local,
// or unspecified using proper CIDR range checks.
func isPrivateAddr(addr netip.Addr) bool {
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() ||
addr.IsLinkLocalMulticast() || addr.IsUnspecified()
Comment on lines +1643 to +1647
}

// Custom Domain (CNAME) operations
// The Ory SDK does not generate API methods for custom domains,
// so we use raw HTTP calls against the console API.
Expand Down
13 changes: 7 additions & 6 deletions internal/client/extract_schemas_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client

import (
"context"
"testing"

ory "github.com/ory/client-go"
Expand All @@ -11,7 +12,7 @@ func TestExtractSchemasFromProjectConfig(t *testing.T) {
project := &ory.Project{
Services: ory.ProjectServices{},
}
schemas, err := extractSchemasFromProjectConfig(project)
schemas, err := extractSchemasFromProjectConfig(context.Background(), project)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -39,7 +40,7 @@ func TestExtractSchemasFromProjectConfig(t *testing.T) {
},
},
}
schemas, err := extractSchemasFromProjectConfig(project)
schemas, err := extractSchemasFromProjectConfig(context.Background(), project)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -74,7 +75,7 @@ func TestExtractSchemasFromProjectConfig(t *testing.T) {
},
},
}
schemas, err := extractSchemasFromProjectConfig(project)
schemas, err := extractSchemasFromProjectConfig(context.Background(), project)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -109,7 +110,7 @@ func TestExtractSchemasFromProjectConfig(t *testing.T) {
},
},
}
_, err := extractSchemasFromProjectConfig(project)
_, err := extractSchemasFromProjectConfig(context.Background(), project)
if err == nil {
t.Fatal("expected error for invalid base64, got nil")
}
Expand All @@ -133,7 +134,7 @@ func TestExtractSchemasFromProjectConfig(t *testing.T) {
},
},
}
_, err := extractSchemasFromProjectConfig(project)
_, err := extractSchemasFromProjectConfig(context.Background(), project)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
Expand Down Expand Up @@ -161,7 +162,7 @@ func TestExtractSchemasFromProjectConfig(t *testing.T) {
},
},
}
schemas, err := extractSchemasFromProjectConfig(project)
schemas, err := extractSchemasFromProjectConfig(context.Background(), project)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
Loading
Loading