OIDC applications

The Maverics Identity Orchestrator can be used as an IDP to protect OIDC apps.

ℹ️
To define OIDC type apps the oidcProvider must be defined.

Configuration options

Name

name is a unique identifier for the app.

Type

type represents the application type. When defining OIDC apps, the type should be oidc.

Client ID

clientID is a unique ID used by client applications to identify themselves.

Credentials

credentials encapsulates the configuration which specifies how the application validates its client credentials.

Secrets

secrets is an array which specifies multiple secret values which can be used for authenticating client applications. The value of the secrets can be stored in a secret provider.

Grant Types

grantTypes are methods through which client applications may obtain tokens through particular authentication flows. If unset, the OIDC app is enabled with authorization_code, client_credentials and refresh_token.

See OIDC Provider Grant Types for more info on supported grant types.

Authentication Redirect URLs

redirectURLs are a list of the allowed URLs the response of an authentication request can be sent.

Logout Redirect URLs

logoutRedirectURLs are a list of the allowed URLs the response of a logout request can be sent.

Claims Mapping

claimsMapping provides a way to map attributes on a user’s session to claims on the ID token. The claims added to the ID token are determined by the value of the scope parameter sent in the authorization request. The scopes supported, in addition to the required openid scope are email, profile, address and phone. The mapping of claim values to a scope is listed in OpenID Connect Core 1.0, section 5.4.

For example, consider an authorization request where the openid and email scopes are requested. Assuming a correct claimsMapping, the ID token would include a sub claim, email claim, and email_verified claim.

Returning a nonce provided in the authorization request via a claim in the ID token response is supported as well.

Attribute Providers

attrProviders are an optional configuration for an identity system or data store from which the OIDCProvider may retrieve additional attributes used in claimsMapping.

Connector

connector is a reference to the name of the defined connector which will be used as an attribute provider.

Username Mapping

usernameMapping defines the attribute that will be used as a search key to query for the user’s attributes.

Authentication

authentication defines how users are authenticated for this app.

IDPs

idps lists the IDPs which will be used to authenticate the user.

IsAuthenticated service extension

isAuthenticatedSE is an optional service extension that can be used to override the default behavior that determines if a user is already authenticated. This extension must be used with authenticateSE.

Authenticate service extension

authenticateSE is an optional service extension used to take control of how authentication will be done. This extension must be used with isAuthenticatedSE.

Backchannel

Authenticate service extension

backchannel.authenticateSE is an optional service extension used to take control of how end-user authentication will be done for backchannel OIDC flows such as Resource Owner Password Credentials Grant.

See Grant Types for more information on enabling password flow.

Load Attributes Service Extension

loadAttrsSE is an optional service extension used to customize how the loading of attributes is done. This extension is often used to load attributes from proprietary data sources such as enterprise APIs.

Authorization

authorization is an optional config which defines access control rules that are required to access the app.

⚠️
If authorization is omitted, it defaults to allowAll: true. All authenticated users will be authorized to access the app.

Allow All

allowAll enables open access to a given app. This option is usually used when fine-grained authorization is not being enforced.

Rules

rules define a list of access control conditions. All rules must evaluate to true in order user to get access to a given app.

And

and defines a list of conditions that must all be true.

Or

or defines a list of conditions where at least one condition must be true.

Operators

The operators defined below can be listed under an or or and rule. All operators expect exactly two values (operands).

In order to load an attribute from the session cache that will be leveraged in policy, use the {{ connectorName.attributeName }} syntax. For example, {{ ldap.memberOf }} could be used to write a policy that was predicated on LDAP group membership.

Additionally, an HTTP request method can be considered during policy evaluation by using the {{ http.request.method }} syntax. The HTTP request method will be returned as an uppercase string, for example, POST. Please note that only the HTTP request method attribute can be used in policy, but support for other HTTP attributes will be added over time.

equals evaluates to true if the two values are equivalent.

notEquals evaluates to true if the two values are not equivalent.

contains evaluates to true if the left-hand operand (the complete string) contains the right-hand operand (a substring).

notContains evaluates to true if the left-hand operand (the complete string) does not contain the right-hand operand (a substring).

IsAuthorized Service Extension

isAuthorizedSE is an optional service extension that can be used to override the default behavior that determines if a user is authorized.

Access Token

accessToken defines the configuration for the OAuth access token.

Type

typecan be set to either jwt (default) or opaque.

Length

length defines the length of an opaque access token. The length can set to between 22 and 256 characters. If unset, the default length is 28 characters.

Lifetime Seconds

lifetimeSeconds controls the lifetime of an access token. By default, access tokens have a lifetime of one hour.

Refresh Token

refreshToken defines the configuration for the OAuth refresh token. Refresh tokens are used to get a new access token without user interaction.

Refresh tokens are automatically rotated with every access token refresh. That is, everytime a refresh token is used to generate a new access token, a new refresh token is also returned. If a previously issued refresh token is reused outside an acceptable window, the active refresh token will be invalidated.

Refresh tokens are rotated to protect against refresh token replay attacks and compromised long-lived refresh tokens. For more information, please reference the OAuth Security Topics RFC.

Allow Offline Access

allowOfflineAccess defines whether a client can request refresh tokens. Refresh tokens will only be issued if the allowOfflineAccess flag is set to true and the authorization request includes the offline_access scope.

ℹ️
If grantTypes are defined, refresh_token must be specified for refresh tokens to be issued. See Grant Types for more info.

Length

length can be set to between 22 and 256 characters to define the length of a refresh token. If unset, the default length is 28 characters.

AllowedAudiences

allowedAudiences is an optional configuration that represents a list of audiences that are allowed to consume access tokens. When a client makes a request to the authorization endpoint, an optional resource parameter can be included that indicates the target audience of the token. The value provided in the resource parameter must be on the list of allowed audiences.

This configuration is used when resource servers, such as APIs, authorize via the access token. For more information, please reference RFC 9068.

BuildIDTokenClaims service extension

The buildIDTokenClaimsSE is an optional service extension that can customize how claims in the ID token are built. This service extension should be used when non-standard claims need to be added to the ID token.

BuildAccessTokenClaims service extension

The buildAccessTokenClaimsSE is an optional service extension that can customize how claims in the access token are built. This service extension should be used when non-standard claims need to be added to the access token.

Examples

Basic OIDC App Config Example

apps:
  - name: exampleOIDCApp
    type: oidc
    clientID: exampleClientID
    credentials:
      secrets:
        - <exampleClientSecretA>
        - <exampleClientSecretB>
    redirectURLs:
      - https://app.enterprise.com/oidc
    logoutRedirectURLs:
      - https://app.enterprise.com/oidc/logout
    attrProviders:
      - connector: ldap
        usernameMapping: okta.email
    authentication:
      idps:
        - okta
    claimsMapping:
      email: ldap.mail
      given_name: ldap.givenname
      family_name: ldap.sn

OIDC App Config Example With Authorization Rules

apps:
  - name: exampleOIDCApp
    type: oidc
    clientID: exampleClientID
    credentials:
      secrets:
        - <exampleClientSecret>
    redirectURLs:
      - https://app.enterprise.com/oidc
    logoutRedirectURLs:
      - https://app.enterprise.com/oidc/logout
    attrProviders:
      - connector: ldap
        usernameMapping: okta.email
    authentication:
      idps:
        - okta
    authorization:
      rules:
        - and:
            - contains: [ "{{ ldap.groups }}", "admin" ]
            - contains: ["{{ okta.email }}", "@example.com"]

OIDC App With Service Extensions

apps:
  - name: exampleOIDCApp
    type: oidc
    clientID: exampleClientID
    credentials:
      secrets:
        - <exampleClientSecret>
    grantTypes:
      - authorization_code
      - password
    redirectURLs:
      - https://app.enterprise.com/oidc
    authentication:
      isAuthenticatedSE:
        funcName: IsAuthenticated
        file: /etc/maverics/extensions/auth.go
      authenticateSE:
        funcName: Authenticate
        file: /etc/maverics/extensions/auth.go
      backchannel:
        authenticateSE:
          funcName: BackchannelAuthenticate
          file: /etc/maverics/extensions/auth.go
    loadAttrsSE:
      funcName: LoadAttributes
      file: /etc/maverics/extensions/auth.go
    authorization:
      isAuthorizedSE:
        funcName: IsAuthorized
        file: /etc/maverics/auth.go
    buildAccessTokenClaimsSE:
      funcName: BuildAccessTokenClaims
      file: /etc/maverics/extensions/auth.go
    buildIDTokenClaimsSE:
      funcName: BuildIDTokenClaims
      file: /etc/maverics/extensions/auth.go
    claimsMapping:
      email: okta.email

/etc/maverics/extensions/auth.go

package main

import (
	"net/http"
    "errors"
    "crypto/sha256"
    "encoding/hex"

	"github.com/strata-io/service-extension/orchestrator"
)

func IsAuthenticated(api orchestrator.Orchestrator, _ http.ResponseWriter, _ *http.Request) bool {
	logger := api.Logger()
	logger.Debug("se", "determining if user is authenticated")

	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return false
	}

	isOktaAuth, err := session.GetString("okta.authenticated")
	if err != nil {
		logger.Error("se", "unable to retrieve session value", "error", err.Error())
		return false
	}
	if isOktaAuth == "true" {
		return true
	}

	return false
}

func Authenticate(api orchestrator.Orchestrator, rw http.ResponseWriter, req *http.Request) {
	logger := api.Logger()
	logger.Debug("se", "authenticating user")

	oktaIDP, err := api.IdentityProvider("okta")
	if err != nil {
		logger.Error(
			"se", "failed to retrieve Okta IDP",
			"error", err.Error(),
		)
		http.Error(
			rw,
			http.StatusText(http.StatusInternalServerError),
			http.StatusInternalServerError,
		)
		return
	}
	oktaIDP.Login(rw, req)
}

func LoadAttributes(api orchestrator.Orchestrator, _ http.ResponseWriter, _ *http.Request) error {
	logger := api.Logger()
	logger.Debug("se", "loading custom attributes from LDAP")
	
	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return err
	}

	mail, err := session.GetString("okta.email")
	if err != nil {
		return fmt.Errorf("failed to find user email required for LDAP query: %w", err)
	}

	ldap, err := api.AttributeProvider("ldap")
	if err != nil {
		return fmt.Errorf("failed to find LDAP attribute provider")
	}

	attrs, err := ldap.Query(mail, []string{"givenname", "sn", "mobile"})
	if err != nil {
		return fmt.Errorf("failed to query LDAP: %w", err)
	}

	for k, v := range attrs {
		logger.Debug(
			"se", "setting LDAP attribute on session",
			"attribute", k,
			"value", v,
		)
		_ = session.SetString(k, v)
	}

	err = session.Save()
	if err != nil {
		return fmt.Errorf("unable to save session state: %w", err)
	}
	return nil
}

func BackchannelAuthenticate(api orchestrator.Orchestrator, req *http.Request) error {
	logger := api.Logger()
	logger.Debug("se", "authenticating user in backchannel flow")

    username := req.Form.Get("username")
    password := req.Form.Get("password")

    secrets, err := api.SecretProvider()
	if err != nil {
		logger.Error(
			"se", "failed to retrieve secret provider",
			"error", err.Error(),
		)
		return errors.New("failed to retrieve secret provider")
	}
    pwdHash := secrets.GetString(username)
    if len(pwdHash) == 0 {
        return errors.New("invalid user credentials")
    }

	hash := sha256.Sum256([]byte(password))
	hashHex := hex.EncodeToString(hash[:])

    if pwdHash != hashHex {
        return errors.New("invalid user credentials")
    }

    return nil
}

func IsAuthorized(api orchestrator.Orchestrator, _ http.ResponseWriter, _ *http.Request) bool {
	logger := api.Logger()
	logger.Debug("se", "determining if user is authorized")

	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return false
	}

	rawGroups, _ := session.GetString("okta.groups")
	groups := strings.Split(rawGroups, ",")
	for _, group := range groups {
		if group == "executives" {
			return true
		}
	}

	return false
}

func BuildAccessTokenClaims(api orchestrator.Orchestrator, _ *http.Request) (map[string]any, error) {
	logger := api.Logger()
	logger.Debug("se", "building access token claims")

	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return nil, err
	}

	roles, err := session.GetString("okta.roles")
	return map[string]any{
		"scope": roles,
	}, err
}

func BuildIDTokenClaims(api orchestrator.Orchestrator, _ *http.Request) (map[string]any, error) {
	logger := api.Logger()
	logger.Debug("se", "building ID token claims")

	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return nil, err
	}

	groups, err := session.GetString("okta.groups")
	return map[string]any{
		"groups": groups,
	}, err
}