Skip to content

Commit

Permalink
feat: add support for oauth2 device authorization grant (#578)
Browse files Browse the repository at this point in the history
  • Loading branch information
shellrausch authored Dec 7, 2021
1 parent 7d92805 commit 6247d06
Show file tree
Hide file tree
Showing 12 changed files with 315 additions and 27 deletions.
5 changes: 5 additions & 0 deletions docs-old/resources/keycloak_realm.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ The attributes below should be specified as [Go duration strings](https://golang
- `access_code_lifespan_user_action` - (Optional) The maximum amount of time a user has to complete login related actions, such as updating a password.
- `action_token_generated_by_user_lifespan` - (Optional) The maximum time a user has to use a user-generated permit before it expires.
- `action_token_generated_by_admin_lifespan` - (Optional) The maximum time a user has to use an admin-generated permit before it expires.
- `oauth2_device_code_lifespan` - (Optional) The maximum amount of time a client has to finish the device code flow before it expires.

The attributes below should be specified in seconds.

- `oauth2_device_polling_interval` - (Optional) The minimum amount of time in seconds that the client should wait between polling requests to the token endpoint.

##### SMTP

Expand Down
3 changes: 3 additions & 0 deletions docs/resources/openid_client.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ is set to `true`.
- `login_theme` - (Optional) The client login theme. This will override the default theme for the realm.
- `exclude_session_state_from_auth_response` - (Optional) When `true`, the parameter `session_state` will not be included in OpenID Connect Authentication Response.
- `use_refresh_tokens` - (Optional) If this is `true`, a refresh_token will be created and added to the token response. If this is `false` then no refresh_token will be generated. Defaults to `true`.
- `oauth2_device_authorization_grant_enabled` - (Optional) Enables support for OAuth 2.0 Device Authorization Grant, which means that client is an application on device that has limited input capabilities or lack a suitable browser.
- `oauth2_device_code_lifespan` - (Optional) The maximum amount of time a client has to finish the device code flow before it expires.
- `oauth2_device_polling_interval` - (Optional) The minimum amount of time in seconds that the client should wait between polling requests to the token endpoint.
- `authorization` - (Optional) When this block is present, fine-grained authorization will be enabled for this client. The client's `access_type` must be `CONFIDENTIAL`, and `service_accounts_enabled` must be `true`. This block has the following arguments:
- `policy_enforcement_mode` - (Required) Dictates how policies are enforced when evaluating authorization requests. Can be one of `ENFORCING`, `PERMISSIVE`, or `DISABLED`.
- `decision_strategy` - (Optional) Dictates how the policies associated with a given permission are evaluated and how a final decision is obtained. Could be one of `AFFIRMATIVE`, `CONSENSUS`, or `UNANIMOUS`. Applies to permissions.
Expand Down
5 changes: 5 additions & 0 deletions docs/resources/realm.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ The arguments below should be specified as [Go duration strings](https://golang.
- `access_code_lifespan_user_action` - (Optional) The maximum amount of time a user has to complete login related actions, such as updating a password.
- `action_token_generated_by_user_lifespan` - (Optional) The maximum time a user has to use a user-generated permit before it expires.
- `action_token_generated_by_admin_lifespan` - (Optional) The maximum time a user has to use an admin-generated permit before it expires.
- `oauth2_device_code_lifespan` - (Optional) The maximum amount of time a client has to finish the device code flow before it expires.

The attributes below should be specified in seconds.

- `oauth2_device_polling_interval` - (Optional) The minimum amount of time in seconds that the client should wait between polling requests to the token endpoint.

### SMTP

Expand Down
29 changes: 16 additions & 13 deletions keycloak/openid_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,22 @@ type OpenidClient struct {
}

type OpenidClientAttributes struct {
PkceCodeChallengeMethod string `json:"pkce.code.challenge.method"`
ExcludeSessionStateFromAuthResponse KeycloakBoolQuoted `json:"exclude.session.state.from.auth.response"`
AccessTokenLifespan string `json:"access.token.lifespan"`
LoginTheme string `json:"login_theme"`
ClientOfflineSessionIdleTimeout string `json:"client.offline.session.idle.timeout,omitempty"`
ClientOfflineSessionMaxLifespan string `json:"client.offline.session.max.lifespan,omitempty"`
ClientSessionIdleTimeout string `json:"client.session.idle.timeout,omitempty"`
ClientSessionMaxLifespan string `json:"client.session.max.lifespan,omitempty"`
UseRefreshTokens KeycloakBoolQuoted `json:"use.refresh.tokens"`
BackchannelLogoutUrl string `json:"backchannel.logout.url"`
BackchannelLogoutRevokeOfflineTokens KeycloakBoolQuoted `json:"backchannel.logout.revoke.offline.tokens"`
BackchannelLogoutSessionRequired KeycloakBoolQuoted `json:"backchannel.logout.session.required"`
ExtraConfig map[string]interface{} `json:"-"`
PkceCodeChallengeMethod string `json:"pkce.code.challenge.method"`
ExcludeSessionStateFromAuthResponse KeycloakBoolQuoted `json:"exclude.session.state.from.auth.response"`
AccessTokenLifespan string `json:"access.token.lifespan"`
LoginTheme string `json:"login_theme"`
ClientOfflineSessionIdleTimeout string `json:"client.offline.session.idle.timeout,omitempty"`
ClientOfflineSessionMaxLifespan string `json:"client.offline.session.max.lifespan,omitempty"`
ClientSessionIdleTimeout string `json:"client.session.idle.timeout,omitempty"`
ClientSessionMaxLifespan string `json:"client.session.max.lifespan,omitempty"`
UseRefreshTokens KeycloakBoolQuoted `json:"use.refresh.tokens"`
BackchannelLogoutUrl string `json:"backchannel.logout.url"`
BackchannelLogoutRevokeOfflineTokens KeycloakBoolQuoted `json:"backchannel.logout.revoke.offline.tokens"`
BackchannelLogoutSessionRequired KeycloakBoolQuoted `json:"backchannel.logout.session.required"`
ExtraConfig map[string]interface{} `json:"-"`
Oauth2DeviceAuthorizationGrantEnabled KeycloakBoolQuoted `json:"oauth2.device.authorization.grant.enabled"`
Oauth2DeviceCodeLifespan string `json:"oauth2.device.code.lifespan,omitempty"`
Oauth2DevicePollingInterval string `json:"oauth2.device.polling.interval,omitempty"`
}

type OpenidAuthenticationFlowBindingOverrides struct {
Expand Down
2 changes: 2 additions & 0 deletions keycloak/realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ type Realm struct {
AccessCodeLifespanUserAction int `json:"accessCodeLifespanUserAction,omitempty"`
ActionTokenGeneratedByUserLifespan int `json:"actionTokenGeneratedByUserLifespan,omitempty"`
ActionTokenGeneratedByAdminLifespan int `json:"actionTokenGeneratedByAdminLifespan,omitempty"`
Oauth2DeviceCodeLifespan int `json:"oauth2DeviceCodeLifespan,omitempty"`
Oauth2DevicePollingInterval int `json:"oauth2DevicePollingInterval,omitempty"`

//internationalization
InternationalizationEnabled bool `json:"internationalizationEnabled"`
Expand Down
13 changes: 13 additions & 0 deletions provider/data_source_keycloak_openid_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,19 @@ func dataSourceKeycloakOpenidClient() *schema.Resource {
Optional: true,
Computed: true,
},
"oauth2_device_authorization_grant_enabled": {
Type: schema.TypeBool,
Optional: true,
Default: false,
},
"oauth2_device_code_lifespan": {
Type: schema.TypeString,
Optional: true,
},
"oauth2_device_polling_interval": {
Type: schema.TypeString,
Optional: true,
},
},
}
}
Expand Down
8 changes: 8 additions & 0 deletions provider/data_source_keycloak_realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,14 @@ func dataSourceKeycloakRealm() *schema.Resource {
Type: schema.TypeString,
Computed: true,
},
"oauth2_device_code_lifespan": {
Type: schema.TypeString,
Computed: true,
},
"oauth2_device_polling_interval": {
Type: schema.TypeInt,
Computed: true,
},

//internationalization
"internationalization": {
Expand Down
45 changes: 32 additions & 13 deletions provider/resource_keycloak_openid_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,19 @@ func resourceKeycloakOpenidClient() *schema.Resource {
Optional: true,
ValidateDiagFunc: validateExtraConfig(reflect.ValueOf(&keycloak.OpenidClientAttributes{}).Elem()),
},
"oauth2_device_authorization_grant_enabled": {
Type: schema.TypeBool,
Optional: true,
Default: false,
},
"oauth2_device_code_lifespan": {
Type: schema.TypeString,
Optional: true,
},
"oauth2_device_polling_interval": {
Type: schema.TypeString,
Optional: true,
},
},
CustomizeDiff: customdiff.ComputedIf("service_account_user_id", func(ctx context.Context, d *schema.ResourceDiff, meta interface{}) bool {
return d.HasChange("service_accounts_enabled")
Expand Down Expand Up @@ -286,19 +299,22 @@ func getOpenidClientFromData(data *schema.ResourceData) (*keycloak.OpenidClient,
ServiceAccountsEnabled: data.Get("service_accounts_enabled").(bool),
FullScopeAllowed: data.Get("full_scope_allowed").(bool),
Attributes: keycloak.OpenidClientAttributes{
PkceCodeChallengeMethod: data.Get("pkce_code_challenge_method").(string),
ExcludeSessionStateFromAuthResponse: keycloak.KeycloakBoolQuoted(data.Get("exclude_session_state_from_auth_response").(bool)),
AccessTokenLifespan: data.Get("access_token_lifespan").(string),
LoginTheme: data.Get("login_theme").(string),
ClientOfflineSessionIdleTimeout: data.Get("client_offline_session_idle_timeout").(string),
ClientOfflineSessionMaxLifespan: data.Get("client_offline_session_max_lifespan").(string),
ClientSessionIdleTimeout: data.Get("client_session_idle_timeout").(string),
ClientSessionMaxLifespan: data.Get("client_session_max_lifespan").(string),
UseRefreshTokens: keycloak.KeycloakBoolQuoted(data.Get("use_refresh_tokens").(bool)),
BackchannelLogoutUrl: data.Get("backchannel_logout_url").(string),
BackchannelLogoutRevokeOfflineTokens: keycloak.KeycloakBoolQuoted(data.Get("backchannel_logout_revoke_offline_sessions").(bool)),
BackchannelLogoutSessionRequired: keycloak.KeycloakBoolQuoted(data.Get("backchannel_logout_session_required").(bool)),
ExtraConfig: getExtraConfigFromData(data),
PkceCodeChallengeMethod: data.Get("pkce_code_challenge_method").(string),
ExcludeSessionStateFromAuthResponse: keycloak.KeycloakBoolQuoted(data.Get("exclude_session_state_from_auth_response").(bool)),
AccessTokenLifespan: data.Get("access_token_lifespan").(string),
LoginTheme: data.Get("login_theme").(string),
ClientOfflineSessionIdleTimeout: data.Get("client_offline_session_idle_timeout").(string),
ClientOfflineSessionMaxLifespan: data.Get("client_offline_session_max_lifespan").(string),
ClientSessionIdleTimeout: data.Get("client_session_idle_timeout").(string),
ClientSessionMaxLifespan: data.Get("client_session_max_lifespan").(string),
UseRefreshTokens: keycloak.KeycloakBoolQuoted(data.Get("use_refresh_tokens").(bool)),
BackchannelLogoutUrl: data.Get("backchannel_logout_url").(string),
BackchannelLogoutRevokeOfflineTokens: keycloak.KeycloakBoolQuoted(data.Get("backchannel_logout_revoke_offline_sessions").(bool)),
BackchannelLogoutSessionRequired: keycloak.KeycloakBoolQuoted(data.Get("backchannel_logout_session_required").(bool)),
ExtraConfig: getExtraConfigFromData(data),
Oauth2DeviceAuthorizationGrantEnabled: keycloak.KeycloakBoolQuoted(data.Get("oauth2_device_authorization_grant_enabled").(bool)),
Oauth2DeviceCodeLifespan: data.Get("oauth2_device_code_lifespan").(string),
Oauth2DevicePollingInterval: data.Get("oauth2_device_polling_interval").(string),
},
ValidRedirectUris: validRedirectUris,
WebOrigins: webOrigins,
Expand Down Expand Up @@ -387,6 +403,9 @@ func setOpenidClientData(keycloakClient *keycloak.KeycloakClient, data *schema.R
data.Set("access_token_lifespan", client.Attributes.AccessTokenLifespan)
data.Set("login_theme", client.Attributes.LoginTheme)
data.Set("use_refresh_tokens", client.Attributes.UseRefreshTokens)
data.Set("oauth2_device_authorization_grant_enabled", client.Attributes.Oauth2DeviceAuthorizationGrantEnabled)
data.Set("oauth2_device_code_lifespan", client.Attributes.Oauth2DeviceCodeLifespan)
data.Set("oauth2_device_polling_interval", client.Attributes.Oauth2DevicePollingInterval)
data.Set("client_offline_session_idle_timeout", client.Attributes.ClientOfflineSessionIdleTimeout)
data.Set("client_offline_session_max_lifespan", client.Attributes.ClientOfflineSessionMaxLifespan)
data.Set("client_session_idle_timeout", client.Attributes.ClientSessionIdleTimeout)
Expand Down
133 changes: 133 additions & 0 deletions provider/resource_keycloak_openid_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,42 @@ func TestAccKeycloakOpenidClient_ClientTimeouts_basic(t *testing.T) {
})
}

func TestAccKeycloakOpenidClient_Device_basic(t *testing.T) {
if ok, _ := keycloakClient.VersionIsGreaterThanOrEqualTo(keycloak.Version_13); !ok {
t.Skip()
}

t.Parallel()
clientId := acctest.RandomWithPrefix("tf-acc")

oauth2DeviceCodeLifespan := "300"
oauth2DevicePollingInterval := "60"
oauth2DeviceAuthorizationGrantEnabled := true

resource.Test(t, resource.TestCase{
ProviderFactories: testAccProviderFactories,
PreCheck: func() { testAccPreCheck(t) },
CheckDestroy: testAccCheckKeycloakOpenidClientDestroy(),
Steps: []resource.TestStep{
{
Config: testKeycloakOpenidClient_oauth2DeviceTimes(clientId,
oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, oauth2DeviceAuthorizationGrantEnabled,
),
Check: testAccCheckKeycloakOpenidClientOauth2Device("keycloak_openid_client.client",
oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, oauth2DeviceAuthorizationGrantEnabled,
),
},
{
ResourceName: "keycloak_openid_client.client",
ImportState: true,
ImportStateVerify: true,
ImportStateIdPrefix: testAccRealm.Realm + "/",
ImportStateVerifyIgnore: []string{"exclude_session_state_from_auth_response"},
},
},
})
}

func TestAccKeycloakOpenidClient_secret(t *testing.T) {
t.Parallel()
clientId := acctest.RandomWithPrefix("tf-acc")
Expand Down Expand Up @@ -596,6 +632,31 @@ func TestAccKeycloakOpenidClient_extraConfigInvalid(t *testing.T) {
})
}

func TestAccKeycloakOpenidClient_oauth2DeviceAuthorizationGrantEnabled(t *testing.T) {
if ok, _ := keycloakClient.VersionIsGreaterThanOrEqualTo(keycloak.Version_13); !ok {
t.Skip()
}

t.Parallel()
clientId := acctest.RandomWithPrefix("tf-acc")

resource.Test(t, resource.TestCase{
ProviderFactories: testAccProviderFactories,
PreCheck: func() { testAccPreCheck(t) },
CheckDestroy: testAccCheckKeycloakOpenidClientDestroy(),
Steps: []resource.TestStep{
{
Config: testKeycloakOpenidClient_oauth2DeviceAuthorizationGrantEnabled(clientId, true),
Check: testAccCheckKeycloakOpenidClientOauth2DeviceAuthorizationGrantEnabled("keycloak_openid_client.client", true),
},
{
Config: testKeycloakOpenidClient_oauth2DeviceAuthorizationGrantEnabled(clientId, false),
Check: testAccCheckKeycloakOpenidClientOauth2DeviceAuthorizationGrantEnabled("keycloak_openid_client.client", false),
},
},
})
}

func testAccCheckKeycloakOpenidClientExistsWithCorrectProtocol(resourceName string) resource.TestCheckFunc {
return func(s *terraform.State) error {
client, err := getOpenidClientFromState(s, resourceName)
Expand Down Expand Up @@ -678,6 +739,30 @@ func testAccCheckKeycloakOpenidClientExistsWithCorrectClientTimeouts(resourceNam
}
}

func testAccCheckKeycloakOpenidClientOauth2Device(resourceName string,
oauth2DeviceCodeLifespan string, Oauth2DevicePollingInterval string, oauth2DeviceAuthorizationGrantEnabled bool) resource.TestCheckFunc {
return func(s *terraform.State) error {
client, err := getOpenidClientFromState(s, resourceName)
if err != nil {
return err
}

if client.Attributes.Oauth2DeviceAuthorizationGrantEnabled != keycloak.KeycloakBoolQuoted(oauth2DeviceAuthorizationGrantEnabled) {
return fmt.Errorf("expected openid client to have device authorizationen granted enabled set to %t, but got %v", oauth2DeviceAuthorizationGrantEnabled, client.Attributes.Oauth2DeviceAuthorizationGrantEnabled)
}

if client.Attributes.Oauth2DeviceCodeLifespan != oauth2DeviceCodeLifespan {
return fmt.Errorf("expected openid client to have device code lifespan set to %s, but got %s", oauth2DeviceCodeLifespan, client.Attributes.Oauth2DeviceCodeLifespan)
}

if client.Attributes.Oauth2DevicePollingInterval != Oauth2DevicePollingInterval {
return fmt.Errorf("expected openid client to have device polling interval set to %s, but got %s", Oauth2DevicePollingInterval, client.Attributes.Oauth2DevicePollingInterval)
}

return nil
}
}

func testAccCheckKeycloakOpenidClientFetch(resourceName string, client *keycloak.OpenidClient) resource.TestCheckFunc {
return func(s *terraform.State) error {
fetchedClient, err := getOpenidClientFromState(s, resourceName)
Expand Down Expand Up @@ -871,6 +956,21 @@ func testAccCheckKeycloakOpenidClientUseRefreshTokens(resourceName string, useRe
}
}

func testAccCheckKeycloakOpenidClientOauth2DeviceAuthorizationGrantEnabled(resourceName string, oauth2DeviceAuthorizationGrantEnabled bool) resource.TestCheckFunc {
return func(s *terraform.State) error {
client, err := getOpenidClientFromState(s, resourceName)
if err != nil {
return err
}

if client.Attributes.Oauth2DeviceAuthorizationGrantEnabled != keycloak.KeycloakBoolQuoted(oauth2DeviceAuthorizationGrantEnabled) {
return fmt.Errorf("expected openid client to have device authorization grant enabled set to %t, but got %v", oauth2DeviceAuthorizationGrantEnabled, client.Attributes.Oauth2DeviceAuthorizationGrantEnabled)
}

return nil
}
}

func testAccCheckKeycloakOpenidClientExtraConfig(resourceName string, key string, value string) resource.TestCheckFunc {
return func(s *terraform.State) error {
client, err := getOpenidClientFromState(s, resourceName)
Expand Down Expand Up @@ -1306,3 +1406,36 @@ resource "keycloak_openid_client" "client" {
}
`, testAccRealm.Realm, clientId, sb.String())
}

func testKeycloakOpenidClient_oauth2DeviceAuthorizationGrantEnabled(clientId string, oauth2DeviceAuthorizationGrantEnabled bool) string {

return fmt.Sprintf(`
data "keycloak_realm" "realm" {
realm = "%s"
}
resource "keycloak_openid_client" "client" {
client_id = "%s"
realm_id = data.keycloak_realm.realm.id
access_type = "CONFIDENTIAL"
oauth2_device_authorization_grant_enabled = %t
}
`, testAccRealm.Realm, clientId, oauth2DeviceAuthorizationGrantEnabled)
}

func testKeycloakOpenidClient_oauth2DeviceTimes(clientId, oauth2DeviceCodeLifespan, oauth2DevicePollingInterval string, oauth2DeviceAuthorizationGrantEnabled bool) string {
return fmt.Sprintf(`
data "keycloak_realm" "realm" {
realm = "%s"
}
resource "keycloak_openid_client" "client" {
client_id = "%s"
realm_id = data.keycloak_realm.realm.id
access_type = "CONFIDENTIAL"
oauth2_device_authorization_grant_enabled = %t
oauth2_device_code_lifespan = "%s"
oauth2_device_polling_interval = "%s"
}
`, testAccRealm.Realm, clientId, oauth2DeviceAuthorizationGrantEnabled, oauth2DeviceCodeLifespan, oauth2DevicePollingInterval)
}
Loading

0 comments on commit 6247d06

Please sign in to comment.