Skip to content
You are viewing the latest documentation for Solo Enterprise for kgateway. To access the documentation for older versions, use the version switcher.

Secure login

Page as Markdown

Set up a secure login for the frontend app with your OIDC provider to enforce user authentication for your portal. A secure login also enables portal self-servicing use cases, including API subscriptions, API credential management, and team setups.

Before you begin

As Portal admin, set up a portal web server.

Step 1: Set up the IdP

To authenticate portal users, you need an OpenID Connect (OIDC) identity provider (IdP). The portal frontend redirects unauthenticated users to the IdP login page and receives a JWT token after successful authentication. The portal uses claims in that token, such as email, name, groups, and sub — to identify the user and determine their access level.

This guide uses Keycloak as the IdP. You can adapt these steps for your own local or cloud IdP.

Install Keycloak

Deploy Keycloak to your cluster and expose it with a load balancer service.

  1. Create a namespace for your Keycloak deployment.

    kubectl create namespace keycloak
  2. Create the Keycloak deployment.

    kubectl -n keycloak apply -f https://raw.githubusercontent.com/solo-io/gloo-mesh-use-cases/main/policy-demo/oidc/keycloak.yaml
  3. Wait for the Keycloak rollout to finish.

    kubectl -n keycloak rollout status deploy/keycloak
  4. Set the Keycloak endpoint details from the load balancer service. If you are running locally in kind and need a local IP address for the load balancer service, consider using cloud-provider-kind.

    export ENDPOINT_KEYCLOAK=$(kubectl -n keycloak get service keycloak -o jsonpath='{.status.loadBalancer.ingress[0].ip}{.status.loadBalancer.ingress[0].hostname}'):8080
    export HOST_KEYCLOAK=$(echo ${ENDPOINT_KEYCLOAK} | cut -d: -f1)
    export PORT_KEYCLOAK=$(echo ${ENDPOINT_KEYCLOAK} | cut -d: -f2)
    export KEYCLOAK_URL=http://${ENDPOINT_KEYCLOAK}
    echo $KEYCLOAK_URL
  5. Open the Keycloak URL and log in with the default admin user. To log in, use the admin username and admin password.

    open $KEYCLOAK_URL

Create a realm and portal client

After Keycloak is running, use the Admin REST API and default admin user to create a dedicated portal realm and configure the portal-frontend OIDC client. The portal-frontend client is used by the portal frontend to initiate the user login flow. To dynamically register OAuth clients on behalf of portal users, the service account of the OIDC client must be assigned the manage-clients role.

  1. Get a JWT token from Keycloak for the default admin user. This token is required for all subsequent commands to create the realm, client, users, and scopes. If you see a parsing error, try running the curl command by itself without piping to jq.

    export KEYCLOAK_TOKEN=$(curl -s \
      -d "client_id=admin-cli" \
      -d "username=admin" \
      -d "password=admin" \
      -d "grant_type=password" \
      "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
      | jq -r .access_token)
    echo $KEYCLOAK_TOKEN
  2. Create the portal realm and the portal-frontend client. Then, create the admin and users groups, and assign the manage-clients role to the portal-frontend service account. The admin and users groups are used to distinguish portal admins from regular portal users based on the groups claim in the JWT. The manage-clients role on the service account allows the portal backend to programmatically create and manage OIDC clients in Keycloak.

    # Create the portal realm
    curl -s -X POST "${KEYCLOAK_URL}/admin/realms" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{"realm":"portal","enabled":true,"sslRequired":"none"}'
    
    # Create the portal-frontend client
    curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/clients" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{
        "clientId": "portal-frontend",
        "enabled": true,
        "clientAuthenticatorType": "client-secret",
        "standardFlowEnabled": true,
        "directAccessGrantsEnabled": true,
        "serviceAccountsEnabled": true,
        "authorizationServicesEnabled": true,
        "redirectUris": ["*"],
        "webOrigins": ["*"],
        "attributes": {
          "post.logout.redirect.uris": "*"
        }
      }'
    
    # Get the client internal ID and secret
    PORTAL_CLIENT_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients?clientId=portal-frontend" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      | jq -r '.[0].id')
    echo "Portal client ID: $PORTAL_CLIENT_ID"
    
    export KEYCLOAK_CLIENT=portal-frontend
    export KEYCLOAK_SECRET=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients/${PORTAL_CLIENT_ID}/client-secret" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      | jq -r '.value')
    echo "Keycloak secret: $KEYCLOAK_SECRET"
    
    # Create admin and users groups
    curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/groups" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{"name":"admin"}'
    
    curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/groups" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{"name":"users"}'
    
    # Refresh the token before role assignment
    export KEYCLOAK_TOKEN=$(curl -s \
      -d "client_id=admin-cli" -d "username=admin" -d "password=admin" \
      -d "grant_type=password" \
      "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
      | jq -r .access_token)
    
    # Get the realm-management client ID
    REALM_MGMT_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients?clientId=realm-management" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      | jq -r '.[0].id')
    
    # Get the manage-clients role
    MANAGE_CLIENTS_ROLE=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients/${REALM_MGMT_ID}/roles/manage-clients" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}")
    
    # Get the service account user ID for portal-frontend
    SA_USER_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients/${PORTAL_CLIENT_ID}/service-account-user" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      | jq -r '.id')
    
    # Assign the manage-clients role to the service account
    curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/users/${SA_USER_ID}/role-mappings/clients/${REALM_MGMT_ID}" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d "[${MANAGE_CLIENTS_ROLE}]"
  3. Verify that the manage-clients role is correctly assigned to the portal-frontend service account.

    curl -s "${KEYCLOAK_URL}/admin/realms/portal/users/${SA_USER_ID}/role-mappings/clients/${REALM_MGMT_ID}" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      | jq '.[].name'

    Example output:

    "manage-clients"

Create JWT claims and test users

The portal frontend reads specific claims from the JWT token to identify users and determine their role in the portal. The following claims must be present in the token:

  • email
  • name
  • preferred_username
  • sub
  • groups

The email, email_verified, preferred_username, sub, name, given_name, and family_name claims are automatically included in Keycloak access tokens when the profile and email scopes are requested. However, the groups claim is not included by default and must be configured explicitly. Use the following steps to add a group membership protocol mapper to the portal-frontend client, then create test users in both the admin and users groups so you can verify the login flow end to end.

  1. Get a fresh admin token and add the groups protocol mapper to the portal-frontend client. The mapper instructs Keycloak to include a groups claim in the access token, ID token, and userinfo response. The claim is an array that includes the names of all groups that the authenticated user belongs to.

    export KEYCLOAK_TOKEN=$(curl -s \
      -d "client_id=admin-cli" -d "username=admin" -d "password=admin" \
      -d "grant_type=password" \
      "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
      | jq -r .access_token)
    
    # Add the groups claim (maps Keycloak group membership to a groups array in the token)
    curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/clients/${PORTAL_CLIENT_ID}/protocol-mappers/models" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "groups",
        "protocol": "openid-connect",
        "protocolMapper": "oidc-group-membership-mapper",
        "config": {
          "claim.name": "groups",
          "full.path": "false",
          "access.token.claim": "true",
          "id.token.claim": "true",
          "userinfo.token.claim": "true"
        }
      }'
  2. Create the user1 test user and assign them to the users group. This user represents a regular portal user who can view APIs, create teams, apps, and subscriptions, and create API credentials, but cannot perform admin-level actions like approving subscriptions or setting up API rate limits.

    curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/users" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{"username":"user1","firstName":"Alice","lastName":"Doe","email":"user1@example.com","enabled":true,"emailVerified":true}'
    
    USER1_ID=$(curl -s -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      "${KEYCLOAK_URL}/admin/realms/portal/users" \
      | python3 -c "import sys,json; print([u['id'] for u in json.load(sys.stdin) if u['username']=='user1'][0])")
    echo "User ID: $USER1_ID"
    
    curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/portal/users/${USER1_ID}/reset-password" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{"type":"password","value":"password","temporary":false}'
    
    USERS_GROUP_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/groups" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      | jq -r '.[] | select(.name=="users") | .id')
    curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/portal/users/${USER1_ID}/groups/${USERS_GROUP_ID}" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}"
  3. Request a token for user1 and decode the JWT payload to confirm that all required claims are present. Look for email, name, preferred_username, sub, email_verified, and a groups array containing "users".

    curl -s -X POST "${KEYCLOAK_URL}/realms/portal/protocol/openid-connect/token" \
      -d "client_id=${KEYCLOAK_CLIENT}" \
      -d "client_secret=${KEYCLOAK_SECRET}" \
      -d "grant_type=password" \
      -d "username=user1" \
      -d "password=password" \
      -d "scope=openid email profile" \
      | python3 -c "
    import sys, json, base64
    d = json.load(sys.stdin)
    if 'error' in d:
        print(json.dumps(d, indent=2))
        sys.exit(1)
    payload = d['access_token'].split('.')[1]
    payload += '=' * (4 - len(payload) % 4)
    print(json.dumps(json.loads(base64.b64decode(payload)), indent=2))
    "

    Example output:

    {
     "exp": 1776095780,
     "iat": 1776095480,
     "auth_time": 1776095479,
     "jti": "79274118-2b5f-4336-8440-107c0c2f2687",
     "iss": "http://172.18.0.30:8080/realms/portal",
     "aud": "account",
     "sub": "024fba27-1e38-4aa5-a247-6a5ea8794c75",
     "typ": "Bearer",
     "azp": "portal-frontend",
     "sid": "d9200443-15e2-491d-bb6c-762adbc36a2f",
     "acr": "1",
     "allowed-origins": ["*"],
     "realm_access": {
      "roles": [
       "offline_access",
       "uma_authorization",
       "default-roles-portal"
      ]
     },
     "resource_access": {
      "account": {
       "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
       ]
      }
     },
     "scope": "openid email profile",
     "email_verified": true,
     "name": "Alice Doe",
     "groups": ["users"],
     "preferred_username": "user1",
     "given_name": "Alice",
     "family_name": "Doe",
     "email": "user1@example.com"
    }
  4. Create the admin1 user and assign them to the admin group. This user represents a portal admin who has elevated privileges in the portal, including approving subscription requests, and setting up credential management and rate limits for APIs.

    export KEYCLOAK_TOKEN=$(curl -s \
      -d "client_id=admin-cli" -d "username=admin" -d "password=admin" \
      -d "grant_type=password" \
      "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
      | jq -r .access_token)
    
    curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/users" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{"username":"admin1","firstName":"Bob","lastName":"Admin","email":"admin1@solo.io","enabled":true,"emailVerified":true}'
    
    ADMIN1_ID=$(curl -s -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      "${KEYCLOAK_URL}/admin/realms/portal/users" \
      | python3 -c "import sys,json; print([u['id'] for u in json.load(sys.stdin) if u['username']=='admin1'][0])")
    echo "Admin user ID: $ADMIN1_ID"
    
    curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/portal/users/${ADMIN1_ID}/reset-password" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      -H "Content-Type: application/json" \
      -d '{"type":"password","value":"password","temporary":false}'
    
    ADMIN_GROUP_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/groups" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \
      | jq -r '.[] | select(.name=="admin") | .id')
    curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/portal/users/${ADMIN1_ID}/groups/${ADMIN_GROUP_ID}" \
      -H "Authorization: Bearer ${KEYCLOAK_TOKEN}"
  5. Request a token for admin1 and decode the JWT payload to confirm that all required claims are present. Verify that the groups array contains "admin", which is what the portal uses to grant admin-level access.

    curl -s -X POST "${KEYCLOAK_URL}/realms/portal/protocol/openid-connect/token" \
      -d "client_id=${KEYCLOAK_CLIENT}" \
      -d "client_secret=${KEYCLOAK_SECRET}" \
      -d "grant_type=password" \
      -d "username=admin1" \
      -d "password=password" \
      -d "scope=openid email profile" \
      | python3 -c "
    import sys, json, base64
    d = json.load(sys.stdin)
    if 'error' in d:
        print(json.dumps(d, indent=2))
        sys.exit(1)
    payload = d['access_token'].split('.')[1]
    payload += '=' * (4 - len(payload) % 4)
    print(json.dumps(json.loads(base64.b64decode(payload)), indent=2))
    "

    Example output:

    {
     "exp": 1776095747,
     "iat": 1776095447,
     "auth_time": 1776095446,
     "jti": "b5efebb7-5075-4dfb-9784-c20f992bb6fb",
     "iss": "http://172.18.0.30:8080/realms/portal",
     "aud": "account",
     "sub": "aa67ed19-3f64-47af-a58a-bc2a69d56b99",
     "typ": "Bearer",
     "azp": "portal-frontend",
     "sid": "31eec05e-76d4-4b87-8e6a-0b3d1ce8d700",
     "acr": "1",
     "allowed-origins": ["*"],
     "realm_access": {
      "roles": [
       "offline_access",
       "uma_authorization",
       "default-roles-portal"
      ]
     },
     "resource_access": {
      "account": {
       "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
       ]
      }
     },
     "scope": "openid email profile",
     "email_verified": true,
     "name": "Bob Admin",
     "groups": ["admin"],
     "preferred_username": "admin1",
     "given_name": "Bob",
     "family_name": "Admin",
     "email": "admin1@solo.io"
    }

Step 2: Secure the portal frontend

With your IdP configured, you can now wire up the portal frontend and gateway to enforce user authentication. When a user logs in to the portal with a JWT that has a valid groups claim, the portal frontend shows self-service features, such as team, app, and subscription management. Note that these features are hidden without a secure login.

  1. Update the portal frontend app to include the OIDC callback and logout paths.

    The VITE_APPLIED_OIDC_AUTH_CODE_CONFIG variable enables the OIDC authorization code flow in the frontend. VITE_OIDC_AUTH_CODE_CONFIG_CALLBACK_PATH and VITE_OIDC_AUTH_CODE_CONFIG_LOGOUT_PATH tell the frontend which paths to use for handling the OIDC redirect after login and the logout redirect, respectively. The paths must match the callbackPath and logoutPath values that you configure in the AuthConfig in a later step.

    kubectl apply -f- <<EOF
    apiVersion: v1
    kind: ServiceAccount
    metadata:
      name: portal-ui
      namespace: default
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: portal-ui
      namespace: default
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: portal-ui
      template:
        metadata:
          labels:
            app: portal-ui
        spec:
          serviceAccountName: portal-ui
          securityContext:
            runAsNonRoot: true
            runAsUser: 10101
          containers:
          - name: portal-ui
            image: gcr.io/solo-public/docs/portal-frontend:v0.1.8
            imagePullPolicy: IfNotPresent
            ports:
            - name: http
              containerPort: 4000
              protocol: TCP
            env:
            - name: VITE_PORTAL_SERVER_URL
              value: "http://portal.example.com:8080/v1"
            - name: VITE_APPLIED_OIDC_AUTH_CODE_CONFIG
              value: "true"
            - name: VITE_OIDC_AUTH_CODE_CONFIG_CALLBACK_PATH
              value: "/v1/login"
            - name: VITE_OIDC_AUTH_CODE_CONFIG_LOGOUT_PATH
              value: "/v1/logout"
            livenessProbe:
              httpGet:
                path: /
                port: http
              initialDelaySeconds: 10
              periodSeconds: 10
            readinessProbe:
              httpGet:
                path: /
                port: http
              initialDelaySeconds: 5
              periodSeconds: 5
            resources:
              requests:
                cpu: 100m
                memory: 128Mi
              limits:
                cpu: 500m
                memory: 512Mi
            securityContext:
              allowPrivilegeEscalation: false
              readOnlyRootFilesystem: true
              capabilities:
                drop:
                - ALL
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: portal-ui
      namespace: default
    spec:
      type: ClusterIP
      ports:
      - port: 4000
        targetPort: http
        protocol: TCP
        name: http
      selector:
        app: portal-ui
    EOF
  2. Create a Kubernetes secret that holds the client-id and client-secret for the portal-frontend OIDC client. The AuthConfig you create in the next step references this secret to authenticate with the IdP when exchanging the authorization code for tokens.

    kubectl apply -f - <<EOF
    apiVersion: v1
    kind: Secret
    metadata:
      name: portal-oidc
      namespace: default
    type: extauth.solo.io/oauth
    stringData:
      client-id: $KEYCLOAK_CLIENT
      client-secret: $KEYCLOAK_SECRET
    EOF
  3. Create the AuthConfig. This resource configures the OIDC authorization code flow at the gateway level. It specifies the IdP issuer URL, the client credentials to use, the callback and logout paths, and the session cookie settings. The accessTokenHeader: id_token field forwards the ID token as a request header to the portal backend so it can extract user identity from it.

    kubectl apply -f - <<EOF
    apiVersion: extauth.solo.io/v1
    kind: AuthConfig
    metadata:
      name: portal-oidc
      namespace: default
    spec:
      configs:
        - oauth2:
            oidcAuthorizationCode:
              appUrl: "http://portal.example.com:8080"
              callbackPath: /v1/login
              logoutPath: /v1/logout
              clientId: $KEYCLOAK_CLIENT
              clientSecretRef:
                name: portal-oidc
                namespace: default
              issuerUrl: "$KEYCLOAK_URL/realms/portal"
              session:
                cookieOptions:
                  notSecure: true
                cookie:
                  allowRefreshing: true
              scopes:
              - email
              - profile
              headers:
                accessTokenHeader: id_token
    EOF
  4. Create a dedicated HTTPRoute for the OIDC login and logout paths, and an EnterpriseKgatewayTrafficPolicy that targets it. All other /v1/ traffic is handled by the my-portal-backend HTTPRoute without authentication. Because the /v1/login and /v1/logout paths are more specific than the /v1/ prefix in my-portal-backend, the gateway routes OIDC callback and logout requests through the dedicated route first.

    kubectl apply -f - <<EOF
    apiVersion: gateway.networking.k8s.io/v1
    kind: HTTPRoute
    metadata:
      name: portal-login
      namespace: default
    spec:
      parentRefs:
      - name: portal-gateway
        namespace: default
      hostnames:
      - "portal.example.com"
      rules:
      - matches:
        - path:
            type: PathPrefix
            value: /v1/login
        - path:
            type: PathPrefix
            value: /v1/logout
        backendRefs:
        - name: portal-my-portal
          namespace: default
          port: 8080
    ---
    apiVersion: enterprisekgateway.solo.io/v1alpha1
    kind: EnterpriseKgatewayTrafficPolicy
    metadata:
      name: trafficpolicy-portal-oidc
      namespace: default
    spec:
      targetRefs:
      - group: gateway.networking.k8s.io
        kind: HTTPRoute
        name: portal-login
      entExtAuth:
        authConfigRef:
          name: portal-oidc
          namespace: default
    EOF
  5. Open the portal in your browser to verify the frontend is reachable.

  6. Click Login. You are redirected to the Keycloak login page. Log in with one of the following test user credentials to verify that both user roles work correctly:

    • Portal user: username: user1, password: password
    • Portal admin: username: admin1, password: password

    Verify that you are redirected back to the portal frontend app and that you see the user login. The portal frontend also shows protected features, such as Teams, Apps, or Subscriptions. The features that you see depend on the type of user that is logged into the frontend.

Next

Explore the following admin tasks.