Repository: openpubkey/openpubkey
Pull request #257 (2025-02-19 23:37:07)

Collection of fixes in opkssh for release 0.5
This PR is a collection of minor bug fixes in prep for release 0.5

- Fixes https://github.com/openpubkey/openpubkey/issues/251
- Fixes https://github.com/openpubkey/openpubkey/issues/256
- Fixes https://github.com/openpubkey/openpubkey/issues/250
- Fixes https://github.com/openpubkey/openpubkey/issues/249


Patch:
diff --git a/opkssh/commands/install.go b/opkssh/commands/install.go
index bd9d5aa..a5265bb 100644
--- a/opkssh/commands/install.go
+++ b/opkssh/commands/install.go
@@ -22,6 +22,7 @@ import (
 	"os/exec"
 )
 
+// Install setups opkssh on a server
 func Install() error {
 	if os.Geteuid() != 0 {
 		return fmt.Errorf("this command must be run as root or sudo")
@@ -48,15 +49,7 @@ sudo chown root /etc/opk/providers
 sudo chmod 600 /etc/opk/providers
 
 if [ -s /etc/opk/providers ]; then
-	read -p "The providers policy file (/etc/opk/providers) is not empty. Do you wish to override the original values? (y/n): " choice
-	case "$choice" in 
-		y|Y ) sudo truncate -s 0 /etc/opk/providers
-			echo "$PROVIDER_GOOGLE" >> /etc/opk/providers
-			echo "$PROVIDER_MICROSOFT" >> /etc/opk/providers
-		;;
-		n|N ) echo "Keeping original providers values.";;
-		* ) echo "Invalid choice. Exiting." && exit 1;;
-	esac
+	echo "The providers policy file (/etc/opk/providers) is not empty. Keeping existing values"
 else
 	echo "$PROVIDER_GOOGLE" >> /etc/opk/providers
 	echo "$PROVIDER_MICROSOFT" >> /etc/opk/providers
@@ -70,11 +63,6 @@ sudo systemctl restart ssh
 `
 	installScript += fmt.Sprintf("sudo cp %s /etc/opk/opkssh\n", opksshExePath)
 
-	// removeScript :=`
-	// rm -rf /etc/opk
-	// rm -rf ~/.opk
-	// `
-
 	cmd := exec.Command("sh", "-c", installScript)
 	cmd.Stdout = os.Stdout
 	cmd.Stderr = os.Stderr
diff --git a/opkssh/commands/login.go b/opkssh/commands/login.go
index b03df38..741e05d 100644
--- a/opkssh/commands/login.go
+++ b/opkssh/commands/login.go
@@ -78,6 +78,12 @@ func login(ctx context.Context, provider client.OpenIdProvider) (*loginResult, e
 		return nil, fmt.Errorf("failed to write SSH keys to filesystem: %w", err)
 	}
 
+	idStr, err := pkt.IdentityString()
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse ID Token: %w", err)
+	}
+	fmt.Printf("Keys generated for identity %s\n", idStr)
+
 	return &loginResult{
 		pkt:        pkt,
 		signer:     signer,
@@ -247,7 +253,7 @@ func writeKeys(seckeyPath string, pubkeyPath string, seckeySshPem []byte, certBy
 		return err
 	}
 
-	log.Printf("writing opk ssh public key to %s and corresponding secret key to %s", pubkeyPath, seckeyPath)
+	fmt.Printf("Writing opk ssh public key to %s and corresponding secret key to %s", pubkeyPath, seckeyPath)
 
 	certBytes = append(certBytes, []byte(" openpubkey")...)
 	// Write ssh public key (certificate) to filesystem
diff --git a/opkssh/main.go b/opkssh/main.go
index 7cce435..8aaac86 100644
--- a/opkssh/main.go
+++ b/opkssh/main.go
@@ -233,10 +233,10 @@ func run() int {
 			Username:         inputPrincipal,
 		}
 		if policyFilePath, err := a.Add(inputPrincipal, inputEmail, inputIssuer); err != nil {
-			log.Println("failed to add to policy:", err)
+			fmt.Println("failed to add to policy:", err)
 			return 1
 		} else {
-			log.Println("Successfully added new policy to", policyFilePath)
+			fmt.Println("Successfully added new policy to", policyFilePath)
 		}
 	case "install":
 		// The "install" command is sets up and installs opkssh on a server.
@@ -246,14 +246,14 @@ func run() int {
 		// 		./opkssh install
 		err := commands.Install()
 		if err != nil {
-			log.Println("failed to install opkssh:", err)
+			fmt.Println("failed to install opkssh:", err)
 			return 1
 		} else {
-			log.Println("successfully installed opkssh")
+			fmt.Println("successfully installed opkssh")
 			return 0
 		}
 	default:
-		log.Println("ERROR! Unrecognized command:", command)
+		fmt.Println("ERROR! Unrecognized command:", command)
 		return 1
 	}
 
diff --git a/opkssh/policy/enforcer.go b/opkssh/policy/enforcer.go
index 8d09fe0..0187d3e 100644
--- a/opkssh/policy/enforcer.go
+++ b/opkssh/policy/enforcer.go
@@ -49,6 +49,7 @@ func (p *Enforcer) CheckPolicy(principalDesired string, pkt *pktoken.PKToken) er
 
 	var claims struct {
 		Email string `json:"email"`
+		Sub   string `json:"sub"`
 	}
 	if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
 		return fmt.Errorf("error unmarshalling pk token payload: %w", err)
@@ -59,12 +60,10 @@ func (p *Enforcer) CheckPolicy(principalDesired string, pkt *pktoken.PKToken) er
 	}
 	for _, user := range policy.Users {
 		// check each entry to see if the user in the claims is included
-		if string(claims.Email) == user.Email {
+		if string(claims.Email) == user.EmailOrSub || string(claims.Sub) == user.EmailOrSub {
 			if issuer != user.Issuer {
 				continue
 			}
-			// log.Printf("claims.email %s, user.Email %s, issuer %s, user.Issuer %s \n", claims.Email, user.Email, issuer, user.Issuer)
-
 			// if they are, then check if the desired principal is allowed
 			if slices.Contains(user.Principals, principalDesired) {
 				// access granted
diff --git a/opkssh/policy/userfileloader.go b/opkssh/policy/userfileloader.go
index a143e15..a2b302d 100644
--- a/opkssh/policy/userfileloader.go
+++ b/opkssh/policy/userfileloader.go
@@ -123,7 +123,7 @@ func (l *UserPolicyLoader) LoadUserPolicy(username string, skipInvalidEntries bo
 			if slices.Contains(user.Principals, username) {
 				// Build clean entry that only gives access to username
 				validUserPolicy.Users = append(validUserPolicy.Users, User{
-					Email:      user.Email,
+					EmailOrSub: user.EmailOrSub,
 					Principals: []string{username},
 					Issuer:     user.Issuer,
 				})
diff --git a/opkssh/policy/userpolicy.go b/opkssh/policy/userpolicy.go
index d3a1788..a4c35c8 100644
--- a/opkssh/policy/userpolicy.go
+++ b/opkssh/policy/userpolicy.go
@@ -26,9 +26,10 @@ import (
 
 // User is an opkssh policy user entry
 type User struct {
-	// Email is the user's email. It is the expected value used when comparing
-	// against an id_token's email claim
-	Email string
+	// EmailOrSub is either the user's email or the user's subscriber ID. It is
+	// the expected value used when comparing against an id_token's email claim
+	// Subscriber ID is a unique identifier for the user at the OpenID Provider
+	EmailOrSub string
 	// Principals is a list of allowed principals
 	Principals []string
 	// Sub        string
@@ -61,7 +62,7 @@ func FromTable(input []byte, path string) *Policy {
 		}
 		user := User{
 			Principals: []string{row[0]},
-			Email:      row[1],
+			EmailOrSub: row[1],
 			Issuer:     row[2],
 		}
 		policy.Users = append(policy.Users, user)
@@ -81,7 +82,7 @@ func (p *Policy) AddAllowedPrincipal(principal string, userEmail string, issuer
 		// file
 		for i := range p.Users {
 			user := &p.Users[i]
-			if user.Email == userEmail && user.Issuer == issuer {
+			if user.EmailOrSub == userEmail && user.Issuer == issuer {
 				principalExists := false
 				for _, p := range user.Principals {
 					// if the principal already exists for this user, then skip
@@ -105,7 +106,7 @@ func (p *Policy) AddAllowedPrincipal(principal string, userEmail string, issuer
 	// new entry
 	if len(p.Users) == 0 || !userExists {
 		newUser := User{
-			Email:      userEmail,
+			EmailOrSub: userEmail,
 			Principals: []string{principal},
 			Issuer:     issuer,
 		}
@@ -119,7 +120,7 @@ func (p *Policy) ToTable() ([]byte, error) {
 	table := files.Table{}
 	for _, user := range p.Users {
 		for _, principal := range user.Principals {
-			table.AddRow(principal, user.Email, user.Issuer)
+			table.AddRow(principal, user.EmailOrSub, user.Issuer)
 		}
 	}
 	return table.ToBytes(), nil
diff --git a/pktoken/pktoken.go b/pktoken/pktoken.go
index 671ffe1..8fb61fb 100644
--- a/pktoken/pktoken.go
+++ b/pktoken/pktoken.go
@@ -137,6 +137,32 @@ func (p *PKToken) Audience() (string, error) {
 	return claims.Audience, nil
 }
 
+// Subscriber returns the subscriber (`sub`) of the ID Token in the PKToken.
+// This is a unique identifier for the user at the OpenID Provider.
+func (p *PKToken) Subscriber() (string, error) {
+	var claims struct {
+		Subscriber string `json:"sub"`
+	}
+	if err := json.Unmarshal(p.Payload, &claims); err != nil {
+		return "", fmt.Errorf("malformatted PK token claims: %w", err)
+	}
+	return claims.Subscriber, nil
+}
+
+// IdentityString string returns the three attributes that are used to uniquely identify a user
+// in the OpenID Connect protocol: the subscriber, the issuer
+func (p *PKToken) IdentityString() (string, error) {
+	sub, err := p.Subscriber()
+	if err != nil {
+		return "", err
+	}
+	iss, err := p.Issuer()
+	if err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("%s %s", sub, iss), nil
+}
+
 // Signs PK Token and then returns only the payload, header and signature as a JWT
 func (p *PKToken) SignToken(
 	signer crypto.Signer,


Test patch:
diff --git a/opkssh/policy/enforcer_test.go b/opkssh/policy/enforcer_test.go
index fc6dd5a..692361d 100644
--- a/opkssh/policy/enforcer_test.go
+++ b/opkssh/policy/enforcer_test.go
@@ -74,17 +74,17 @@ func NewMockOpenIdProvider2(gqSign bool, issuer string, clientID string, extraCl
 var policyTest = &policy.Policy{
 	Users: []policy.User{
 		{
-			Email:      "alice@bastionzero.com",
+			EmailOrSub: "alice@bastionzero.com",
 			Principals: []string{"test"},
 			Issuer:     "https://accounts.example.com",
 		},
 		{
-			Email:      "arthur.aardvark@example.com",
+			EmailOrSub: "arthur.aardvark@example.com",
 			Principals: []string{"test"},
 			Issuer:     "https://accounts.example.com",
 		},
 		{
-			Email:      "bob@example.com",
+			EmailOrSub: "bob@example.com",
 			Principals: []string{"test"},
 			Issuer:     "https://accounts.example.com",
 		},
@@ -94,11 +94,11 @@ var policyTest = &policy.Policy{
 var policyTestNoEntry = &policy.Policy{
 	Users: []policy.User{
 		{
-			Email:      "alice@bastionzero.com",
+			EmailOrSub: "alice@bastionzero.com",
 			Principals: []string{"test"},
 		},
 		{
-			Email:      "bob@example.com",
+			EmailOrSub: "bob@example.com",
 			Principals: []string{"test"},
 		},
 	},
diff --git a/opkssh/policy/multiuserloader_test.go b/opkssh/policy/multiuserloader_test.go
index defd94a..004b581 100644
--- a/opkssh/policy/multiuserloader_test.go
+++ b/opkssh/policy/multiuserloader_test.go
@@ -30,11 +30,11 @@ import (
 func policyToMapUsers(p *policy.Policy) map[string]policy.User {
 	m := make(map[string]*policy.User)
 	for e, user := range p.Users {
-		if seenUserEntry, ok := m[user.Email]; ok {
+		if seenUserEntry, ok := m[user.EmailOrSub]; ok {
 			seenUserEntry.Principals = append(seenUserEntry.Principals, user.Principals...)
 		} else {
 			entry := p.Users[e]
-			m[user.Email] = &entry
+			m[user.EmailOrSub] = &entry
 		}
 	}
 
@@ -71,14 +71,14 @@ func TestLoad(t *testing.T) {
 			rootPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
 				},
 			},
 			expectedUsers: map[string]policy.User{
-				"alice@example.com": {Email: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com"},
+				"alice@example.com": {EmailOrSub: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com"},
 			},
 		},
 		{
@@ -86,14 +86,14 @@ func TestLoad(t *testing.T) {
 			userPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{ValidUser.Username, "bob"},
 						Issuer:     "https://example.com",
 					},
 				},
 			},
 			expectedUsers: map[string]policy.User{
-				"alice@example.com": {Email: "alice@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com"},
+				"alice@example.com": {EmailOrSub: "alice@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com"},
 			},
 		},
 		{
@@ -101,12 +101,12 @@ func TestLoad(t *testing.T) {
 			rootPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
 					{
-						Email:      "charlie@example.com",
+						EmailOrSub: "charlie@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
@@ -115,21 +115,21 @@ func TestLoad(t *testing.T) {
 			userPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{ValidUser.Username},
 						Issuer:     "https://example.com",
 					},
 					{
-						Email:      "bob@example.com",
+						EmailOrSub: "bob@example.com",
 						Principals: []string{ValidUser.Username},
 						Issuer:     "https://example.com",
 					},
 				},
 			},
 			expectedUsers: map[string]policy.User{
-				"alice@example.com":   {Email: "alice@example.com", Principals: []string{"test", ValidUser.Username}, Issuer: "https://example.com"},
-				"bob@example.com":     {Email: "bob@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com"},
-				"charlie@example.com": {Email: "charlie@example.com", Principals: []string{"test"}, Issuer: "https://example.com"},
+				"alice@example.com":   {EmailOrSub: "alice@example.com", Principals: []string{"test", ValidUser.Username}, Issuer: "https://example.com"},
+				"bob@example.com":     {EmailOrSub: "bob@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com"},
+				"charlie@example.com": {EmailOrSub: "charlie@example.com", Principals: []string{"test"}, Issuer: "https://example.com"},
 			},
 		},
 		{
@@ -137,12 +137,12 @@ func TestLoad(t *testing.T) {
 			rootPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
 					{
-						Email:      "charlie@example.com",
+						EmailOrSub: "charlie@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
@@ -151,25 +151,25 @@ func TestLoad(t *testing.T) {
 			userPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
 					{
-						Email:      "bob@example.com",
+						EmailOrSub: "bob@example.com",
 						Principals: []string{"test", "test2"},
 						Issuer:     "https://example.com",
 					},
 					{
-						Email:      "charlie@example.com",
+						EmailOrSub: "charlie@example.com",
 						Principals: []string{"test", "test2", "test3"},
 						Issuer:     "https://example.com",
 					},
 				},
 			},
 			expectedUsers: map[string]policy.User{
-				"alice@example.com":   {Email: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com"},
-				"charlie@example.com": {Email: "charlie@example.com", Principals: []string{"test"}, Issuer: "https://example.com"},
+				"alice@example.com":   {EmailOrSub: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com"},
+				"charlie@example.com": {EmailOrSub: "charlie@example.com", Principals: []string{"test"}, Issuer: "https://example.com"},
 			},
 		},
 	}
@@ -226,7 +226,7 @@ func TestLoad(t *testing.T) {
 				for email, expectedEntry := range tt.expectedUsers {
 					gotEntry, ok := gotUsers[email]
 					if assert.True(t, ok, "policy should have entry for email %s", email) {
-						assert.Equal(t, expectedEntry.Email, gotEntry.Email)
+						assert.Equal(t, expectedEntry.EmailOrSub, gotEntry.EmailOrSub)
 						assert.ElementsMatch(t, expectedEntry.Principals, gotEntry.Principals)
 					}
 				}
diff --git a/opkssh/policy/providerpolicy_test.go b/opkssh/policy/providerpolicy_test.go
new file mode 100644
index 0000000..9afe616
--- /dev/null
+++ b/opkssh/policy/providerpolicy_test.go
@@ -0,0 +1,178 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package policy
+
+// Note: These tests were originally generated by o3-mini and then heavily modified
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/openpubkey/openpubkey/verifier"
+	"github.com/stretchr/testify/require"
+)
+
+// Test for ProvidersPolicyRow.GetExpirationPolicy.
+func TestProvidersPolicyRow_GetExpirationPolicy(t *testing.T) {
+	tests := []struct {
+		input     string
+		expected  verifier.ExpirationPolicy
+		expectErr bool
+	}{
+		{"24h", verifier.ExpirationPolicies.MAX_AGE_24HOURS, false},
+		{"48h", verifier.ExpirationPolicies.MAX_AGE_48HOURS, false},
+		{"1week", verifier.ExpirationPolicies.MAX_AGE_1WEEK, false},
+		{"oidc", verifier.ExpirationPolicies.OIDC, false},
+		{"oidc_refreshed", verifier.ExpirationPolicies.OIDC_REFRESHED, false},
+		{"never", verifier.ExpirationPolicies.NEVER_EXPIRE, false},
+		{"invalid", verifier.ExpirationPolicy{}, true},
+	}
+
+	for _, tc := range tests {
+		row := ProvidersPolicyRow{ExpirationPolicy: tc.input}
+		res, err := row.GetExpirationPolicy()
+		if tc.expectErr {
+			if err == nil {
+				t.Errorf("expected error for input %s, got nil", tc.input)
+			}
+		} else {
+			if err != nil {
+				t.Errorf("unexpected error for input %s: %v", tc.input, err)
+			}
+			if res != tc.expected {
+				t.Errorf("for input %s, expected %v, got %v", tc.input, tc.expected, res)
+			}
+		}
+	}
+}
+
+// Test for ProviderPolicy.ToString.
+func TestProviderPolicy_ToString(t *testing.T) {
+	policy := ProviderPolicy{}
+	policy.AddRow(ProvidersPolicyRow{Issuer: "issuer1", ClientID: "client1", ExpirationPolicy: "24h"})
+	policy.AddRow(ProvidersPolicyRow{Issuer: "issuer2", ClientID: "client2", ExpirationPolicy: "48h"})
+	expected := "issuer1 client1 24h\nissuer2 client2 48h\n"
+	require.Equal(t, expected, policy.ToString())
+}
+
+// Test ProviderPolicy.CreateVerifier with a valid Google issuer.
+func TestProviderPolicy_CreateVerifier_Google(t *testing.T) {
+	policy := &ProviderPolicy{}
+	policy.AddRow(ProvidersPolicyRow{
+		Issuer:           "https://accounts.google.com",
+		ClientID:         "test-google",
+		ExpirationPolicy: "24h",
+	})
+	ver, err := policy.CreateVerifier()
+	require.NoError(t, err)
+	require.NotNil(t, ver)
+}
+
+// Test ProviderPolicy.CreateVerifier with a valid Azure issuer.
+func TestProviderPolicy_CreateVerifier_Azure(t *testing.T) {
+	policy := &ProviderPolicy{}
+	policy.AddRow(ProvidersPolicyRow{
+		Issuer:           "https://login.microsoftonline.com/tenant",
+		ClientID:         "test-azure",
+		ExpirationPolicy: "48h",
+	})
+	ver, err := policy.CreateVerifier()
+	require.NoError(t, err)
+	require.NotNil(t, ver)
+}
+
+// Test ProviderPolicy.CreateVerifier with an unsupported issuer.
+func TestProviderPolicy_CreateVerifier_UnsupportedIssuer(t *testing.T) {
+	policy := &ProviderPolicy{}
+	policy.AddRow(ProvidersPolicyRow{
+		Issuer:           "https://unsupported.com",
+		ClientID:         "test-unsupported",
+		ExpirationPolicy: "24h",
+	})
+	ver, err := policy.CreateVerifier()
+	require.ErrorContains(t, err, "unsupported issuer")
+	require.Nil(t, ver)
+}
+
+// Test ProviderPolicy.CreateVerifier with an invalid expiration policy.
+func TestProviderPolicy_CreateVerifier_InvalidExpiration(t *testing.T) {
+	policy := &ProviderPolicy{}
+	policy.AddRow(ProvidersPolicyRow{
+		Issuer:           "https://accounts.google.com",
+		ClientID:         "test-google",
+		ExpirationPolicy: "invalid",
+	})
+	ver, err := policy.CreateVerifier()
+	require.ErrorContains(t, err, "invalid expiration policy")
+	require.Nil(t, ver)
+}
+
+// Test ProviderPolicy.CreateVerifier when no providers are configured.
+func TestProviderPolicy_CreateVerifier_NoProviders(t *testing.T) {
+	policy := &ProviderPolicy{}
+	ver, err := policy.CreateVerifier()
+	require.ErrorContains(t, err, "no providers configured")
+	require.Nil(t, ver)
+}
+
+// Test ProvidersFileLoader.FromTable with valid and invalid rows.
+func TestProvidersFileLoader_FromTable(t *testing.T) {
+	// Input with two valid rows and one invalid row.
+	input := []byte("https://accounts.google.com test-google 24h\n" +
+		"invalid-line\n" +
+		"https://login.microsoftonline.com/tenant test-azure 48h\n")
+	loader := ProvidersFileLoader{}
+	policy := loader.FromTable(input, "dummy-path")
+	require.Equal(t, 2, len(policy.rows))
+	// Check the first row.
+	row1 := policy.rows[0]
+	if row1.Issuer != "https://accounts.google.com" || row1.ClientID != "test-google" || row1.ExpirationPolicy != "24h" {
+		t.Error("first row does not match expected values")
+	}
+	// Check the second row.
+	row2 := policy.rows[1]
+	if !strings.HasPrefix(row2.Issuer, "https://login.microsoftonline.com") ||
+		row2.ClientID != "test-azure" ||
+		row2.ExpirationPolicy != "48h" {
+		t.Error("second row does not match expected values")
+	}
+}
+
+// Test ProvidersFileLoader.ToTable.
+func TestProvidersFileLoader_ToTable(t *testing.T) {
+	policy := ProviderPolicy{}
+	policy.AddRow(ProvidersPolicyRow{
+		Issuer:           "issuer1",
+		ClientID:         "client1",
+		ExpirationPolicy: "24h",
+	})
+	policy.AddRow(ProvidersPolicyRow{
+		Issuer:           "issuer2",
+		ClientID:         "client2",
+		ExpirationPolicy: "48h",
+	})
+	loader := ProvidersFileLoader{}
+	table := loader.ToTable(policy)
+	rows := table.GetRows()
+	require.Equal(t, 2, len(rows))
+	if rows[0][0] != "issuer1" || rows[0][1] != "client1" || rows[0][2] != "24h" {
+		t.Error("first row in table does not match expected values")
+	}
+	if rows[1][0] != "issuer2" || rows[1][1] != "client2" || rows[1][2] != "48h" {
+		t.Error("second row in table does not match expected values")
+	}
+}
diff --git a/opkssh/policy/userfileloader_test.go b/opkssh/policy/userfileloader_test.go
index 450d362..0298285 100644
--- a/opkssh/policy/userfileloader_test.go
+++ b/opkssh/policy/userfileloader_test.go
@@ -144,7 +144,7 @@ func TestLoadUserPolicy_Success(t *testing.T) {
 	testPolicy := &policy.Policy{
 		Users: []policy.User{
 			{
-				Email:      "alice@example.com",
+				EmailOrSub: "alice@example.com",
 				Principals: []string{"test"},
 				Issuer:     "https://example.com",
 			},
@@ -175,22 +175,22 @@ func TestLoadUserPolicy_Success_SkipInvalidEntries(t *testing.T) {
 	testPolicy := &policy.Policy{
 		Users: []policy.User{
 			{
-				Email:      "alice@example.com",
+				EmailOrSub: "alice@example.com",
 				Principals: []string{"test"},
 				Issuer:     "https://example.com",
 			},
 			{
-				Email:      "bob@example.com",
+				EmailOrSub: "bob@example.com",
 				Principals: []string{"test"},
 				Issuer:     "https://example.com",
 			},
 			{
-				Email:      "charlie@example.com",
+				EmailOrSub: "charlie@example.com",
 				Principals: []string{ValidUser.Username},
 				Issuer:     "https://example.com",
 			},
 			{
-				Email:      "daniel@example.com",
+				EmailOrSub: "daniel@example.com",
 				Principals: []string{ValidUser.Username, "test", "test2"},
 				Issuer:     "https://example.com",
 			},
@@ -200,12 +200,12 @@ func TestLoadUserPolicy_Success_SkipInvalidEntries(t *testing.T) {
 	expectedPolicy := &policy.Policy{
 		Users: []policy.User{
 			{
-				Email:      "charlie@example.com",
+				EmailOrSub: "charlie@example.com",
 				Principals: []string{ValidUser.Username},
 				Issuer:     "https://example.com",
 			},
 			{
-				Email:      "daniel@example.com",
+				EmailOrSub: "daniel@example.com",
 				Principals: []string{ValidUser.Username},
 				Issuer:     "https://example.com",
 			},
@@ -314,7 +314,7 @@ func TestLoadSystemDefaultPolicy_Success(t *testing.T) {
 	testPolicy := &policy.Policy{
 		Users: []policy.User{
 			{
-				Email:      "alice@example.com",
+				EmailOrSub: "alice@example.com",
 				Principals: []string{"test"},
 				Issuer:     "https://example.com",
 			},
@@ -339,7 +339,7 @@ func TestDump_Success(t *testing.T) {
 	testPolicy := &policy.Policy{
 		Users: []policy.User{
 			{
-				Email:      "alice@example.com",
+				EmailOrSub: "alice@example.com",
 				Principals: []string{"test"},
 				Issuer:     "https://example.com",
 			},
diff --git a/opkssh/policy/userpolicy_test.go b/opkssh/policy/userpolicy_test.go
index 0e03465..7abfa68 100644
--- a/opkssh/policy/userpolicy_test.go
+++ b/opkssh/policy/userpolicy_test.go
@@ -44,7 +44,7 @@ func TestAddAllowedPrincipal(t *testing.T) {
 			expectedPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
@@ -58,7 +58,7 @@ func TestAddAllowedPrincipal(t *testing.T) {
 			initialPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test", "test2"},
 						Issuer:     "https://example.com",
 					},
@@ -66,12 +66,12 @@ func TestAddAllowedPrincipal(t *testing.T) {
 			expectedPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "bob@example.com",
+						EmailOrSub: "bob@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test", "test2"},
 						Issuer:     "https://example.com",
 					},
@@ -85,7 +85,7 @@ func TestAddAllowedPrincipal(t *testing.T) {
 			initialPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test", "test2"},
 						Issuer:     "https://example.com",
 					},
@@ -93,7 +93,7 @@ func TestAddAllowedPrincipal(t *testing.T) {
 			expectedPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test", "test2", "test3"},
 						Issuer:     "https://example.com",
 					},
@@ -107,7 +107,7 @@ func TestAddAllowedPrincipal(t *testing.T) {
 			initialPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
@@ -115,7 +115,7 @@ func TestAddAllowedPrincipal(t *testing.T) {
 			expectedPolicy: &policy.Policy{
 				Users: []policy.User{
 					{
-						Email:      "alice@example.com",
+						EmailOrSub: "alice@example.com",
 						Principals: []string{"test"},
 						Issuer:     "https://example.com",
 					},
diff --git a/opkssh/test/integration/add_test.go b/opkssh/test/integration/add_test.go
index dc69902..423b81e 100644
--- a/opkssh/test/integration/add_test.go
+++ b/opkssh/test/integration/add_test.go
@@ -154,7 +154,7 @@ func TestAdd(t *testing.T) {
 				expectedPolicy := &policy.Policy{
 					Users: []policy.User{
 						{
-							Email:      "foo@example.com",
+							EmailOrSub: "foo@example.com",
 							Principals: []string{tt.desiredPrincipal},
 							Issuer:     issuer,
 						},