Skip to content
Closed
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
34 changes: 18 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,31 @@ name: KindeCI

on:
push:
branches: [ "main" ]
branches: ['main']
pull_request:
branches: [ "main" ]
branches: ['main']

jobs:

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v3

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
# - name: Build
# run: go build -v ./...

# - name: Build
# run: go build -v ./...
- name: Apply management_api post-generation patches
run: cd kinde/management_api && go run fix_create_identity_identity.go

- name: Test
run: go test -v ./...
- name: Test
run: go test -v ./...

- id: govulncheck
uses: golang/govulncheck-action@v1
with:
go-package: ./...
- id: govulncheck
uses: golang/govulncheck-action@v1
with:
go-package: ./...
4 changes: 3 additions & 1 deletion frameworks/gin_kinde/gin_kinde.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ func UseKindeAuth(router *gin.RouterGroup, kindeDomain, clientID, clientSecret,
if kindeClient, ok := client.(*authorization_code.AuthorizationCodeFlow); 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
82 changes: 82 additions & 0 deletions kinde/management_api/create_identity_response_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package management_api

// CreateIdentity response decode tests. The decoder is patched by fix_create_identity_identity.go
// to accept "identity_id" from the API; CI runs that patch before tests (see .github/workflows/ci.yml).

import (
"testing"

"github.com/go-faster/jx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCreateIdentityResponse_Decode_WithId verifies that a response with "id" field decodes correctly.
func TestCreateIdentityResponse_Decode_WithId(t *testing.T) {
// Standard API response with "id" field
json := `{
"message": "Identity created",
"code": "IDENTITY_CREATED",
"identity": {
"id": "idl_abc123"
}
}`

d := jx.DecodeBytes([]byte(json))
var response CreateIdentityResponse

err := response.Decode(d)
require.NoError(t, err)

assert.True(t, response.Identity.IsSet(), "Identity should be set")
identity, ok := response.Identity.Get()
require.True(t, ok)
assert.True(t, identity.ID.IsSet(), "Identity ID should be set")
id, ok := identity.ID.Get()
require.True(t, ok)
assert.Equal(t, "idl_abc123", id, "Identity ID should be decoded correctly")
}

// TestCreateIdentityResponse_Decode_WithIdentityId verifies that when the API returns
// "identity_id" (e.g. for existing enterprise identity), the patched decoder maps it to ID.
func TestCreateIdentityResponse_Decode_WithIdentityId(t *testing.T) {
// API response when creating identity with existing enterprise value - returns "identity_id" not "id"
json := `{
"message": "Identity created",
"code": "IDENTITY_CREATED",
"identity": {
"identity_id": "idl_existing_enterprise_123"
}
}`

d := jx.DecodeBytes([]byte(json))
var response CreateIdentityResponse

err := response.Decode(d)
require.NoError(t, err)

assert.True(t, response.Identity.IsSet(), "Identity should be set")
identity, ok := response.Identity.Get()
require.True(t, ok)
assert.True(t, identity.ID.IsSet(), "Identity ID should be set (from identity_id field)")
id, ok := identity.ID.Get()
require.True(t, ok)
assert.Equal(t, "idl_existing_enterprise_123", id, "Identity ID should be decoded from identity_id field")
Comment thread
BrandtKruger marked this conversation as resolved.
}

// TestCreateIdentityResponseIdentity_Decode_IdentityIdField verifies the identity object
// decoder accepts "identity_id" and populates ID (for API compatibility).
func TestCreateIdentityResponseIdentity_Decode_IdentityIdField(t *testing.T) {
json := `{"identity_id": "idl_xyz789"}`

d := jx.DecodeBytes([]byte(json))
var identity CreateIdentityResponseIdentity

err := identity.Decode(d)
require.NoError(t, err)

assert.True(t, identity.ID.IsSet(), "ID should be set from identity_id field")
id, ok := identity.ID.Get()
require.True(t, ok)
assert.Equal(t, "idl_xyz789", id)
}
111 changes: 111 additions & 0 deletions kinde/management_api/fix_create_identity_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//go:build ignore
// +build ignore

package main

import (
"bytes"
"fmt"
"os"
)

// This tool patches the generated oas_json_gen.go so that CreateIdentityResponseIdentity.Decode()
// accepts the "identity_id" field returned by the Kinde API (e.g. when creating identity with
// existing enterprise identity). The OpenAPI schema uses "id" but the API may return "identity_id".

const (
targetFile = "oas_json_gen.go"
)

// Exact block that appears only in CreateIdentityResponseIdentity.Decode (full ObjBytes callback
// including the unique wrap "decode CreateIdentityResponseIdentity" so we match exactly once).
var oldBlock = []byte(` if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error {
switch string(k) {
case "id":
if err := func() error {
s.ID.Reset()
if err := s.ID.Decode(d); err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"id\"")
}
default:
return d.Skip()
}
return nil
}); err != nil {
return errors.Wrap(err, "decode CreateIdentityResponseIdentity")
}`)

// Same block with identity_id case inserted before default.
var newBlock = []byte(` if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error {
switch string(k) {
case "id":
if err := func() error {
s.ID.Reset()
if err := s.ID.Decode(d); err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"id\"")
}
case "identity_id":
// API returns identity_id (e.g. for existing enterprise identity); map to ID for compatibility.
if err := func() error {
s.ID.Reset()
if err := s.ID.Decode(d); err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"identity_id\"")
}
default:
return d.Skip()
}
return nil
}); err != nil {
return errors.Wrap(err, "decode CreateIdentityResponseIdentity")
}`)

func main() {
fmt.Printf("Patching %s for CreateIdentityResponseIdentity identity_id...\n", targetFile)

content, err := os.ReadFile(targetFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
os.Exit(1)
}

// Only replace the first occurrence (CreateIdentityResponseIdentity is the only decoder with this exact block).
if bytes.Contains(content, []byte("case \"identity_id\":\n\t\t\t// API returns identity_id")) {
fmt.Println("Already patched - identity_id case present")
os.Exit(0)
}

count := bytes.Count(content, oldBlock)
if count == 0 {
fmt.Println("Pattern not found - generator output may have changed")
fmt.Println("Please verify CreateIdentityResponseIdentity.Decode manually")
os.Exit(1)
}
Comment thread
BrandtKruger marked this conversation as resolved.
if count > 1 {
fmt.Fprintf(os.Stderr, "Pattern matched %d times; expected 1 (CreateIdentityResponseIdentity). Refusing to patch.\n", count)
os.Exit(1)
}

newContent := bytes.Replace(content, oldBlock, newBlock, 1)
if bytes.Equal(content, newContent) {
fmt.Println("No changes made")
os.Exit(0)
}

if err := os.WriteFile(targetFile, newContent, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
fmt.Println("Successfully patched CreateIdentityResponseIdentity.Decode() for identity_id")
}
1 change: 1 addition & 0 deletions kinde/management_api/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ package management_api

//go:generate go run github.com/ogen-go/ogen/cmd/ogen --target . -package management_api --clean https://api-spec.kinde.com/kinde-management-api-spec.yaml
//go:generate go run fix_optstring.go
//go:generate go run fix_create_identity_identity.go
16 changes: 16 additions & 0 deletions oauth2/authorization_code/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ type (
IAuthorizationCodeFlow interface {
// Logout clears the session and token.
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 @@ -133,7 +137,13 @@ func (flow *AuthorizationCodeFlow) StartDeviceAuth(ctx context.Context) (*oauth2

// Returns the URL to redirect the user to start authentication pipeline.
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 @@ -143,6 +153,12 @@ func (flow *AuthorizationCodeFlow) GetAuthURL() string {
}
}

// Add invitation code parameters if provided
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
81 changes: 81 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,87 @@ 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 getTestAuthorizationServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

Expand Down
11 changes: 11 additions & 0 deletions oauth2/authorization_code/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,14 @@ func WithPKCEChallengeMethod(method 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) {
if invitationCode != "" {
WithAuthParameter("invitation_code", invitationCode)(s)
WithAuthParameter("is_invitation", "true")(s)
}
}
}
Loading