Skip to content

Commit e9fbcc0

Browse files
authored
Merge pull request #51 from kinde-oss/feature/team-member-invitations
Feature/team member invitations
2 parents a1bbe39 + e1ef07a commit e9fbcc0

4 files changed

Lines changed: 348 additions & 2 deletions

File tree

frameworks/gin_kinde/gin_kinde.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,9 @@ func UseKindeAuth(router *gin.RouterGroup, kindeDomain, clientID, clientSecret,
355355
if kindeClient, ok := client.(authorization_code.IAuthorizationCodeFlow); ok {
356356

357357
if isAuthenticated, _ := kindeClient.IsAuthenticated(context.Background()); !isAuthenticated {
358-
authURL := kindeClient.GetAuthURL()
358+
// Check for invitation_code query parameter
359+
invitationCode := ctx.Query("invitation_code")
360+
authURL := kindeClient.GetAuthURLWithInvitation(invitationCode)
359361
ctx.Redirect(302, authURL)
360362
ctx.Abort()
361363
}

oauth2/authorization_code/authorization_code.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@ type (
4040

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

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

219+
// Add invitation code parameters if provided
220+
invitationCode = strings.TrimSpace(invitationCode)
221+
if invitationCode != "" {
222+
query.Set("invitation_code", invitationCode)
223+
query.Set("is_invitation", "true")
224+
}
225+
209226
// Add PKCE parameters if enabled
210227
if flow.usePKCE {
211228
query.Set("code_challenge", flow.codeChallenge)

oauth2/authorization_code/authorization_code_test.go

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,321 @@ func TestAutorizationCodeFlowClient(t *testing.T) {
8888

8989
}
9090

91+
func TestGetAuthURLWithInvitation(t *testing.T) {
92+
assert := assert.New(t)
93+
94+
testBackendServerURL := "https://api.com"
95+
testKindeServerURL := "https://mytest.kinde.com"
96+
97+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
98+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
99+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
100+
WithSessionHooks(newTestSessionHooks()),
101+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
102+
)
103+
104+
// Test with invitation code
105+
invitationCode := "inv_123456789"
106+
authURL := kindeAuthFlow.GetAuthURLWithInvitation(invitationCode)
107+
assert.NotEmpty(authURL, "AuthURL cannot be empty")
108+
assert.Contains(authURL, "invitation_code=inv_123456789", "AuthURL should contain invitation_code parameter")
109+
assert.Contains(authURL, "is_invitation=true", "AuthURL should contain is_invitation parameter")
110+
111+
// Test without invitation code (empty string)
112+
authURLNoInvitation := kindeAuthFlow.GetAuthURLWithInvitation("")
113+
assert.NotEmpty(authURLNoInvitation, "AuthURL cannot be empty")
114+
assert.NotContains(authURLNoInvitation, "invitation_code", "AuthURL should not contain invitation_code when empty")
115+
assert.NotContains(authURLNoInvitation, "is_invitation", "AuthURL should not contain is_invitation when empty")
116+
}
117+
118+
func TestWithInvitationCodeOption(t *testing.T) {
119+
assert := assert.New(t)
120+
121+
testBackendServerURL := "https://api.com"
122+
testKindeServerURL := "https://mytest.kinde.com"
123+
124+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
125+
invitationCode := "inv_987654321"
126+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
127+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
128+
WithSessionHooks(newTestSessionHooks()),
129+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
130+
WithInvitationCode(invitationCode),
131+
)
132+
133+
flow := kindeAuthFlow.(*AuthorizationCodeFlow)
134+
invitationCodeValues, hasInvitationCode := flow.authURLOptions["invitation_code"]
135+
assert.True(hasInvitationCode, "invitation_code should be set in authURLOptions")
136+
if hasInvitationCode {
137+
assert.Contains(invitationCodeValues, invitationCode, "invitation_code should contain the provided value")
138+
}
139+
140+
isInvitationValues, hasIsInvitation := flow.authURLOptions["is_invitation"]
141+
assert.True(hasIsInvitation, "is_invitation should be set in authURLOptions")
142+
if hasIsInvitation {
143+
assert.Contains(isInvitationValues, "true", "is_invitation should be set to 'true'")
144+
}
145+
146+
authURL := kindeAuthFlow.GetAuthURL()
147+
assert.Contains(authURL, "invitation_code=inv_987654321", "AuthURL should contain invitation_code parameter")
148+
assert.Contains(authURL, "is_invitation=true", "AuthURL should contain is_invitation parameter")
149+
}
150+
151+
func TestWithInvitationCodeOptionEmpty(t *testing.T) {
152+
assert := assert.New(t)
153+
154+
testBackendServerURL := "https://api.com"
155+
testKindeServerURL := "https://mytest.kinde.com"
156+
157+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
158+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
159+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
160+
WithSessionHooks(newTestSessionHooks()),
161+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
162+
WithInvitationCode(""), // Empty invitation code should not add parameters
163+
)
164+
165+
flow := kindeAuthFlow.(*AuthorizationCodeFlow)
166+
_, hasInvitationCode := flow.authURLOptions["invitation_code"]
167+
_, hasIsInvitation := flow.authURLOptions["is_invitation"]
168+
assert.False(hasInvitationCode, "invitation_code should not be set when empty")
169+
assert.False(hasIsInvitation, "is_invitation should not be set when empty")
170+
}
171+
172+
func TestWithInvitationCodeOptionWhitespace(t *testing.T) {
173+
assert := assert.New(t)
174+
175+
testBackendServerURL := "https://api.com"
176+
testKindeServerURL := "https://mytest.kinde.com"
177+
178+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
179+
testCases := []string{" ", " ", "\t", "\n", " \t\n "}
180+
181+
for _, whitespaceCode := range testCases {
182+
t.Run(fmt.Sprintf("whitespace_%q", whitespaceCode), func(t *testing.T) {
183+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
184+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
185+
WithSessionHooks(newTestSessionHooks()),
186+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
187+
WithInvitationCode(whitespaceCode), // Whitespace-only invitation code should not add parameters
188+
)
189+
190+
flow := kindeAuthFlow.(*AuthorizationCodeFlow)
191+
_, hasInvitationCode := flow.authURLOptions["invitation_code"]
192+
_, hasIsInvitation := flow.authURLOptions["is_invitation"]
193+
assert.False(hasInvitationCode, "invitation_code should not be set when whitespace-only")
194+
assert.False(hasIsInvitation, "is_invitation should not be set when whitespace-only")
195+
})
196+
}
197+
}
198+
199+
// TestGetAuthURLWithInvitationParameterPrecedence tests that invitation code parameter
200+
// takes precedence over option when both are provided
201+
func TestGetAuthURLWithInvitationParameterPrecedence(t *testing.T) {
202+
assert := assert.New(t)
203+
204+
testBackendServerURL := "https://api.com"
205+
testKindeServerURL := "https://mytest.kinde.com"
206+
207+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
208+
optionInvitationCode := "inv_from_option"
209+
parameterInvitationCode := "inv_from_parameter"
210+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
211+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
212+
WithSessionHooks(newTestSessionHooks()),
213+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
214+
WithInvitationCode(optionInvitationCode),
215+
)
216+
217+
// When GetAuthURLWithInvitation is called with a parameter, it should override the option
218+
authURL := kindeAuthFlow.GetAuthURLWithInvitation(parameterInvitationCode)
219+
assert.Contains(authURL, fmt.Sprintf("invitation_code=%s", parameterInvitationCode), "Parameter invitation code should take precedence")
220+
assert.Contains(authURL, "is_invitation=true", "is_invitation should be set when parameter is provided")
221+
assert.NotContains(authURL, optionInvitationCode, "Option invitation code should not appear when parameter is provided")
222+
}
223+
224+
// TestGetAuthURLWithInvitationWithOtherOptions tests that invitation code works
225+
// correctly when combined with other options like PKCE, audience, etc.
226+
func TestGetAuthURLWithInvitationWithOtherOptions(t *testing.T) {
227+
assert := assert.New(t)
228+
229+
testBackendServerURL := "https://api.com"
230+
testKindeServerURL := "https://mytest.kinde.com"
231+
232+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
233+
invitationCode := "inv_combined_test"
234+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
235+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
236+
WithSessionHooks(newTestSessionHooks()),
237+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
238+
WithInvitationCode(invitationCode),
239+
WithAudience("http://my.api.com/api"),
240+
WithPKCE(),
241+
)
242+
243+
authURL := kindeAuthFlow.GetAuthURLWithInvitation(invitationCode)
244+
assert.Contains(authURL, fmt.Sprintf("invitation_code=%s", invitationCode), "Should contain invitation_code")
245+
assert.Contains(authURL, "is_invitation=true", "Should contain is_invitation")
246+
assert.Contains(authURL, "audience=http%3A%2F%2Fmy.api.com%2Fapi", "Should contain audience parameter")
247+
assert.Contains(authURL, "code_challenge=", "Should contain PKCE code_challenge")
248+
assert.Contains(authURL, "code_challenge_method=S256", "Should contain PKCE method")
249+
}
250+
251+
// TestGetAuthURLWithInvitationSpecialCharacters tests URL encoding of invitation codes
252+
// with special characters
253+
func TestGetAuthURLWithInvitationSpecialCharacters(t *testing.T) {
254+
assert := assert.New(t)
255+
256+
testBackendServerURL := "https://api.com"
257+
testKindeServerURL := "https://mytest.kinde.com"
258+
259+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
260+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
261+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
262+
WithSessionHooks(newTestSessionHooks()),
263+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
264+
)
265+
266+
testCases := []struct {
267+
name string
268+
invitationCode string
269+
expectedInURL string
270+
}{
271+
{
272+
name: "invitation code with spaces",
273+
invitationCode: "inv code with spaces",
274+
expectedInURL: "invitation_code=inv+code+with+spaces",
275+
},
276+
{
277+
name: "invitation code with special chars",
278+
invitationCode: "inv_123-456@789",
279+
expectedInURL: "invitation_code=inv_123-456%40789",
280+
},
281+
{
282+
name: "invitation code with unicode",
283+
invitationCode: "inv_测试_123",
284+
expectedInURL: "invitation_code=inv_%E6%B5%8B%E8%AF%95_123",
285+
},
286+
}
287+
288+
for _, tc := range testCases {
289+
t.Run(tc.name, func(t *testing.T) {
290+
authURL := kindeAuthFlow.GetAuthURLWithInvitation(tc.invitationCode)
291+
assert.Contains(authURL, tc.expectedInURL, "URL should contain properly encoded invitation code")
292+
assert.Contains(authURL, "is_invitation=true", "Should contain is_invitation parameter")
293+
})
294+
}
295+
}
296+
297+
// TestGetAuthURLWithInvitationMultipleCalls tests that multiple calls with different
298+
// invitation codes work correctly
299+
func TestGetAuthURLWithInvitationMultipleCalls(t *testing.T) {
300+
assert := assert.New(t)
301+
302+
testBackendServerURL := "https://api.com"
303+
testKindeServerURL := "https://mytest.kinde.com"
304+
305+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
306+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
307+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
308+
WithSessionHooks(newTestSessionHooks()),
309+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
310+
)
311+
312+
// First call with invitation code
313+
invitationCode1 := "inv_first"
314+
authURL1 := kindeAuthFlow.GetAuthURLWithInvitation(invitationCode1)
315+
assert.Contains(authURL1, fmt.Sprintf("invitation_code=%s", invitationCode1), "First URL should contain first invitation code")
316+
assert.Contains(authURL1, "is_invitation=true", "First URL should contain is_invitation")
317+
318+
// Second call with different invitation code
319+
invitationCode2 := "inv_second"
320+
authURL2 := kindeAuthFlow.GetAuthURLWithInvitation(invitationCode2)
321+
assert.Contains(authURL2, fmt.Sprintf("invitation_code=%s", invitationCode2), "Second URL should contain second invitation code")
322+
assert.Contains(authURL2, "is_invitation=true", "Second URL should contain is_invitation")
323+
assert.NotContains(authURL2, invitationCode1, "Second URL should not contain first invitation code")
324+
325+
// Third call without invitation code
326+
authURL3 := kindeAuthFlow.GetAuthURLWithInvitation("")
327+
assert.NotContains(authURL3, "invitation_code", "Third URL should not contain invitation_code")
328+
assert.NotContains(authURL3, "is_invitation", "Third URL should not contain is_invitation")
329+
}
330+
331+
// TestGetAuthURLWithInvitationOptionAndEmptyParameter tests that when option is set
332+
// but empty parameter is passed, the option values are still used (since empty parameter
333+
// doesn't override the option values in authURLOptions)
334+
func TestGetAuthURLWithInvitationOptionAndEmptyParameter(t *testing.T) {
335+
assert := assert.New(t)
336+
337+
testBackendServerURL := "https://api.com"
338+
testKindeServerURL := "https://mytest.kinde.com"
339+
340+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
341+
optionInvitationCode := "inv_from_option"
342+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
343+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
344+
WithSessionHooks(newTestSessionHooks()),
345+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
346+
WithInvitationCode(optionInvitationCode),
347+
)
348+
349+
// When empty parameter is passed, it doesn't override option values
350+
// The option values are already in authURLOptions and will be included
351+
authURL := kindeAuthFlow.GetAuthURLWithInvitation("")
352+
assert.Contains(authURL, fmt.Sprintf("invitation_code=%s", optionInvitationCode), "Should use invitation code from option when parameter is empty")
353+
assert.Contains(authURL, "is_invitation=true", "Should contain is_invitation from option")
354+
}
355+
356+
// TestGetAuthURLWithInvitationWhitespaceOnly tests that whitespace-only invitation codes
357+
// are treated as empty
358+
func TestGetAuthURLWithInvitationWhitespaceOnly(t *testing.T) {
359+
assert := assert.New(t)
360+
361+
testBackendServerURL := "https://api.com"
362+
testKindeServerURL := "https://mytest.kinde.com"
363+
364+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
365+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
366+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
367+
WithSessionHooks(newTestSessionHooks()),
368+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
369+
)
370+
371+
testCases := []string{" ", " ", "\t", "\n", " \t\n "}
372+
373+
for _, whitespaceCode := range testCases {
374+
t.Run(fmt.Sprintf("whitespace_%q", whitespaceCode), func(t *testing.T) {
375+
authURL := kindeAuthFlow.GetAuthURLWithInvitation(whitespaceCode)
376+
// Whitespace-only codes should be trimmed and treated as empty
377+
assert.NotContains(authURL, "invitation_code=", "AuthURL should not contain invitation_code parameter for whitespace-only codes")
378+
assert.NotContains(authURL, "is_invitation=", "AuthURL should not contain is_invitation parameter for whitespace-only codes")
379+
})
380+
}
381+
}
382+
383+
// TestGetAuthURLIncludesInvitationCodeFromOption tests that GetAuthURL() includes
384+
// invitation code when set via option
385+
func TestGetAuthURLIncludesInvitationCodeFromOption(t *testing.T) {
386+
assert := assert.New(t)
387+
388+
testBackendServerURL := "https://api.com"
389+
testKindeServerURL := "https://mytest.kinde.com"
390+
391+
callbackURL := fmt.Sprintf("%v/callback", testBackendServerURL)
392+
invitationCode := "inv_via_option"
393+
kindeAuthFlow, _ := NewAuthorizationCodeFlow(
394+
testKindeServerURL, "b9da18c441b44d81bab3e8232de2e18d", "client_secret", callbackURL,
395+
WithSessionHooks(newTestSessionHooks()),
396+
WithCustomStateGenerator(func(*AuthorizationCodeFlow) string { return "test_state" }),
397+
WithInvitationCode(invitationCode),
398+
)
399+
400+
// GetAuthURL() should include invitation code from option
401+
authURL := kindeAuthFlow.GetAuthURL()
402+
assert.Contains(authURL, fmt.Sprintf("invitation_code=%s", invitationCode), "GetAuthURL should include invitation code from option")
403+
assert.Contains(authURL, "is_invitation=true", "GetAuthURL should include is_invitation from option")
404+
}
405+
91406
func getTestAuthorizationServer() *httptest.Server {
92407
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93408

oauth2/authorization_code/options.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,15 @@ func WithReauthState(reauthState string) Option {
453453
}
454454
}
455455
}
456+
457+
// WithInvitationCode sets the invitation code and is_invitation parameters for team member invitations.
458+
// When an invitation code is provided, is_invitation will be set to "true".
459+
func WithInvitationCode(invitationCode string) Option {
460+
return func(s *AuthorizationCodeFlow) {
461+
invitationCode = strings.TrimSpace(invitationCode)
462+
if invitationCode != "" {
463+
WithAuthParameter("invitation_code", invitationCode)(s)
464+
WithAuthParameter("is_invitation", "true")(s)
465+
}
466+
}
467+
}

0 commit comments

Comments
 (0)