Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion frameworks/gin_kinde/gin_kinde.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,9 @@ func UseKindeAuth(router *gin.RouterGroup, kindeDomain, clientID, clientSecret,
if kindeClient, ok := client.(authorization_code.IAuthorizationCodeFlow); ok {

if isAuthenticated, _ := kindeClient.IsAuthenticated(context.Background()); !isAuthenticated {
authURL := kindeClient.GetAuthURL()
// Check for invitation_code query parameter
invitationCode := ctx.Query("invitation_code")
authURL := kindeClient.GetAuthURLWithInvitation(invitationCode)
ctx.Redirect(302, authURL)
ctx.Abort()
}
Expand Down
19 changes: 18 additions & 1 deletion oauth2/authorization_code/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ type (

// IAuthorizationCodeFlow represents the interface for the authorization code flow.
IAuthorizationCodeFlow interface {
// Logout clears the session and token.
// Returns the URL to redirect the user to start authentication pipeline.
GetAuthURL() string
// GetAuthURLWithInvitation returns the URL to redirect the user to start authentication pipeline
// with invitation code support. If invitationCode is provided, it will include both
// invitation_code and is_invitation parameters in the auth URL.
GetAuthURLWithInvitation(invitationCode string) string
// Exchanges the authorization code for a token and establishes KindeContext.
ExchangeCode(ctx context.Context, authorizationCode string, receivedState string) error
// Returns http client to call external services, will refresh token behind the scenes if offline is requested.
Expand Down Expand Up @@ -196,7 +200,13 @@ func (flow *AuthorizationCodeFlow) StartDeviceAuth(ctx context.Context) (*oauth2
// authURL := flow.GetAuthURL()
// http.Redirect(w, r, authURL, http.StatusFound)
func (flow *AuthorizationCodeFlow) GetAuthURL() string {
return flow.GetAuthURLWithInvitation("")
}

// GetAuthURLWithInvitation returns the URL to redirect the user to start authentication pipeline
// with invitation code support. If invitationCode is provided, it will include both
// invitation_code and is_invitation parameters in the auth URL.
func (flow *AuthorizationCodeFlow) GetAuthURLWithInvitation(invitationCode string) string {
state := flow.stateGenerator(flow)
url, _ := url.Parse(flow.config.AuthCodeURL(state))
query := url.Query()
Expand All @@ -206,6 +216,13 @@ func (flow *AuthorizationCodeFlow) GetAuthURL() string {
}
}

// Add invitation code parameters if provided
invitationCode = strings.TrimSpace(invitationCode)
if invitationCode != "" {
query.Set("invitation_code", invitationCode)
query.Set("is_invitation", "true")
}

// Add PKCE parameters if enabled
if flow.usePKCE {
query.Set("code_challenge", flow.codeChallenge)
Expand Down
315 changes: 315 additions & 0 deletions oauth2/authorization_code/authorization_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,321 @@ func TestAutorizationCodeFlowClient(t *testing.T) {

}

func TestGetAuthURLWithInvitation(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
)

// Test with invitation code
invitationCode := "inv_123456789"
authURL := kindeAuthFlow.GetAuthURLWithInvitation(invitationCode)
assert.NotEmpty(authURL, "AuthURL cannot be empty")
assert.Contains(authURL, "invitation_code=inv_123456789", "AuthURL should contain invitation_code parameter")
assert.Contains(authURL, "is_invitation=true", "AuthURL should contain is_invitation parameter")

// Test without invitation code (empty string)
authURLNoInvitation := kindeAuthFlow.GetAuthURLWithInvitation("")
assert.NotEmpty(authURLNoInvitation, "AuthURL cannot be empty")
assert.NotContains(authURLNoInvitation, "invitation_code", "AuthURL should not contain invitation_code when empty")
assert.NotContains(authURLNoInvitation, "is_invitation", "AuthURL should not contain is_invitation when empty")
}

func TestWithInvitationCodeOption(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
invitationCode := "inv_987654321"
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
WithInvitationCode(invitationCode),
)

flow := kindeAuthFlow.(*AuthorizationCodeFlow)
invitationCodeValues, hasInvitationCode := flow.authURLOptions["invitation_code"]
assert.True(hasInvitationCode, "invitation_code should be set in authURLOptions")
if hasInvitationCode {
assert.Contains(invitationCodeValues, invitationCode, "invitation_code should contain the provided value")
}

isInvitationValues, hasIsInvitation := flow.authURLOptions["is_invitation"]
assert.True(hasIsInvitation, "is_invitation should be set in authURLOptions")
if hasIsInvitation {
assert.Contains(isInvitationValues, "true", "is_invitation should be set to 'true'")
}

authURL := kindeAuthFlow.GetAuthURL()
assert.Contains(authURL, "invitation_code=inv_987654321", "AuthURL should contain invitation_code parameter")
assert.Contains(authURL, "is_invitation=true", "AuthURL should contain is_invitation parameter")
}

func TestWithInvitationCodeOptionEmpty(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
WithInvitationCode(""), // Empty invitation code should not add parameters
)

flow := kindeAuthFlow.(*AuthorizationCodeFlow)
_, hasInvitationCode := flow.authURLOptions["invitation_code"]
_, hasIsInvitation := flow.authURLOptions["is_invitation"]
assert.False(hasInvitationCode, "invitation_code should not be set when empty")
assert.False(hasIsInvitation, "is_invitation should not be set when empty")
}

func TestWithInvitationCodeOptionWhitespace(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
testCases := []string{" ", " ", "\t", "\n", " \t\n "}

for _, whitespaceCode := range testCases {
t.Run(fmt.Sprintf("whitespace_%q", whitespaceCode), func(t *testing.T) {
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
WithInvitationCode(whitespaceCode), // Whitespace-only invitation code should not add parameters
)

flow := kindeAuthFlow.(*AuthorizationCodeFlow)
_, hasInvitationCode := flow.authURLOptions["invitation_code"]
_, hasIsInvitation := flow.authURLOptions["is_invitation"]
assert.False(hasInvitationCode, "invitation_code should not be set when whitespace-only")
assert.False(hasIsInvitation, "is_invitation should not be set when whitespace-only")
})
}
}

// TestGetAuthURLWithInvitationParameterPrecedence tests that invitation code parameter
// takes precedence over option when both are provided
func TestGetAuthURLWithInvitationParameterPrecedence(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
optionInvitationCode := "inv_from_option"
parameterInvitationCode := "inv_from_parameter"
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
WithInvitationCode(optionInvitationCode),
)

// When GetAuthURLWithInvitation is called with a parameter, it should override the option
authURL := kindeAuthFlow.GetAuthURLWithInvitation(parameterInvitationCode)
assert.Contains(authURL, fmt.Sprintf("invitation_code=%s", parameterInvitationCode), "Parameter invitation code should take precedence")
assert.Contains(authURL, "is_invitation=true", "is_invitation should be set when parameter is provided")
assert.NotContains(authURL, optionInvitationCode, "Option invitation code should not appear when parameter is provided")
}

// TestGetAuthURLWithInvitationWithOtherOptions tests that invitation code works
// correctly when combined with other options like PKCE, audience, etc.
func TestGetAuthURLWithInvitationWithOtherOptions(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
invitationCode := "inv_combined_test"
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
WithInvitationCode(invitationCode),
WithAudience("http://my.api.com/api"),
WithPKCE(),
)

authURL := kindeAuthFlow.GetAuthURLWithInvitation(invitationCode)
assert.Contains(authURL, fmt.Sprintf("invitation_code=%s", invitationCode), "Should contain invitation_code")
assert.Contains(authURL, "is_invitation=true", "Should contain is_invitation")
assert.Contains(authURL, "audience=http%3A%2F%2Fmy.api.com%2Fapi", "Should contain audience parameter")
assert.Contains(authURL, "code_challenge=", "Should contain PKCE code_challenge")
assert.Contains(authURL, "code_challenge_method=S256", "Should contain PKCE method")
}

// TestGetAuthURLWithInvitationSpecialCharacters tests URL encoding of invitation codes
// with special characters
func TestGetAuthURLWithInvitationSpecialCharacters(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
)

testCases := []struct {
name string
invitationCode string
expectedInURL string
}{
{
name: "invitation code with spaces",
invitationCode: "inv code with spaces",
expectedInURL: "invitation_code=inv+code+with+spaces",
},
{
name: "invitation code with special chars",
invitationCode: "inv_123-456@789",
expectedInURL: "invitation_code=inv_123-456%40789",
},
{
name: "invitation code with unicode",
invitationCode: "inv_测试_123",
expectedInURL: "invitation_code=inv_%E6%B5%8B%E8%AF%95_123",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
authURL := kindeAuthFlow.GetAuthURLWithInvitation(tc.invitationCode)
assert.Contains(authURL, tc.expectedInURL, "URL should contain properly encoded invitation code")
assert.Contains(authURL, "is_invitation=true", "Should contain is_invitation parameter")
})
}
}

// TestGetAuthURLWithInvitationMultipleCalls tests that multiple calls with different
// invitation codes work correctly
func TestGetAuthURLWithInvitationMultipleCalls(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
)

// First call with invitation code
invitationCode1 := "inv_first"
authURL1 := kindeAuthFlow.GetAuthURLWithInvitation(invitationCode1)
assert.Contains(authURL1, fmt.Sprintf("invitation_code=%s", invitationCode1), "First URL should contain first invitation code")
assert.Contains(authURL1, "is_invitation=true", "First URL should contain is_invitation")

// Second call with different invitation code
invitationCode2 := "inv_second"
authURL2 := kindeAuthFlow.GetAuthURLWithInvitation(invitationCode2)
assert.Contains(authURL2, fmt.Sprintf("invitation_code=%s", invitationCode2), "Second URL should contain second invitation code")
assert.Contains(authURL2, "is_invitation=true", "Second URL should contain is_invitation")
assert.NotContains(authURL2, invitationCode1, "Second URL should not contain first invitation code")

// Third call without invitation code
authURL3 := kindeAuthFlow.GetAuthURLWithInvitation("")
assert.NotContains(authURL3, "invitation_code", "Third URL should not contain invitation_code")
assert.NotContains(authURL3, "is_invitation", "Third URL should not contain is_invitation")
}

// TestGetAuthURLWithInvitationOptionAndEmptyParameter tests that when option is set
// but empty parameter is passed, the option values are still used (since empty parameter
// doesn't override the option values in authURLOptions)
func TestGetAuthURLWithInvitationOptionAndEmptyParameter(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
optionInvitationCode := "inv_from_option"
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
WithInvitationCode(optionInvitationCode),
)

// When empty parameter is passed, it doesn't override option values
// The option values are already in authURLOptions and will be included
authURL := kindeAuthFlow.GetAuthURLWithInvitation("")
assert.Contains(authURL, fmt.Sprintf("invitation_code=%s", optionInvitationCode), "Should use invitation code from option when parameter is empty")
assert.Contains(authURL, "is_invitation=true", "Should contain is_invitation from option")
}

// TestGetAuthURLWithInvitationWhitespaceOnly tests that whitespace-only invitation codes
// are treated as empty
func TestGetAuthURLWithInvitationWhitespaceOnly(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
)

testCases := []string{" ", " ", "\t", "\n", " \t\n "}

for _, whitespaceCode := range testCases {
t.Run(fmt.Sprintf("whitespace_%q", whitespaceCode), func(t *testing.T) {
authURL := kindeAuthFlow.GetAuthURLWithInvitation(whitespaceCode)
// Whitespace-only codes should be trimmed and treated as empty
assert.NotContains(authURL, "invitation_code=", "AuthURL should not contain invitation_code parameter for whitespace-only codes")
assert.NotContains(authURL, "is_invitation=", "AuthURL should not contain is_invitation parameter for whitespace-only codes")
})
}
}

// TestGetAuthURLIncludesInvitationCodeFromOption tests that GetAuthURL() includes
// invitation code when set via option
func TestGetAuthURLIncludesInvitationCodeFromOption(t *testing.T) {
assert := assert.New(t)

testBackendServerURL := "https://api.com"
testKindeServerURL := "https://mytest.kinde.com"

callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
invitationCode := "inv_via_option"
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
WithSessionHooks(newTestSessionHooks()),
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
WithInvitationCode(invitationCode),
)

// GetAuthURL() should include invitation code from option
authURL := kindeAuthFlow.GetAuthURL()
assert.Contains(authURL, fmt.Sprintf("invitation_code=%s", invitationCode), "GetAuthURL should include invitation code from option")
assert.Contains(authURL, "is_invitation=true", "GetAuthURL should include is_invitation from option")
}

func getTestAuthorizationServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

Expand Down
12 changes: 12 additions & 0 deletions oauth2/authorization_code/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,3 +453,15 @@ func WithReauthState(reauthState string) Option {
}
}
}

// WithInvitationCode sets the invitation code and is_invitation parameters for team member invitations.
// When an invitation code is provided, is_invitation will be set to "true".
func WithInvitationCode(invitationCode string) Option {
return func(s *AuthorizationCodeFlow) {
invitationCode = strings.TrimSpace(invitationCode)
if invitationCode != "" {
WithAuthParameter("invitation_code", invitationCode)(s)
WithAuthParameter("is_invitation", "true")(s)
}
}
}
Loading