Skip to content

Commit 9b5c745

Browse files
authored
Oauth composite name (#1299)
* published is a better choice than created for releases. * Support for composite user name * Added explantion for the composite name support * Better docs for oauth json paths
1 parent f279e68 commit 9b5c745

3 files changed

Lines changed: 253 additions & 33 deletions

File tree

app/services/oauth/oauth.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,13 @@ func parseOAuthRawProfile(ctx context.Context, c *cmd.ParseOAuthRawProfile) erro
116116
}
117117

118118
query := jsonq.New(c.Body)
119+
120+
// Extract and combine name parts
121+
name := extractCompositeName(query, config.JSONUserNamePath)
122+
119123
profile := &dto.OAuthUserProfile{
120124
ID: strings.TrimSpace(query.String(config.JSONUserIDPath)),
121-
Name: strings.TrimSpace(query.String(config.JSONUserNamePath)),
125+
Name: name,
122126
Email: strings.ToLower(strings.TrimSpace(query.String(config.JSONUserEmailPath))),
123127
}
124128

@@ -143,6 +147,52 @@ func parseOAuthRawProfile(ctx context.Context, c *cmd.ParseOAuthRawProfile) erro
143147
return nil
144148
}
145149

150+
// extractCompositeName handles composite name selectors
151+
// Format can be:
152+
// - Simple path: "name"
153+
// - Fallback paths: "name, login" (tries name, if empty tries login)
154+
// - Composite paths: "firstname + ' ' + lastname" (combines multiple fields)
155+
func extractCompositeName(query *jsonq.Query, namePath string) string {
156+
// Check if it's a composite path (contains '+')
157+
if strings.Contains(namePath, "+") {
158+
// Split by '+' and process each part
159+
parts := strings.Split(namePath, "+")
160+
var result strings.Builder
161+
162+
for _, part := range parts {
163+
part = strings.TrimSpace(part)
164+
165+
// If it's a string literal (enclosed in quotes or single quotes)
166+
if (strings.HasPrefix(part, "'") && strings.HasSuffix(part, "'")) ||
167+
(strings.HasPrefix(part, "\"") && strings.HasSuffix(part, "\"")) {
168+
// Extract the literal without quotes
169+
literal := part[1 : len(part)-1]
170+
result.WriteString(literal)
171+
} else {
172+
// It's a JSON path
173+
value := strings.TrimSpace(query.String(part))
174+
if value != "" {
175+
result.WriteString(value)
176+
}
177+
}
178+
}
179+
180+
return strings.TrimSpace(result.String())
181+
}
182+
183+
// Handle fallback paths (comma-separated)
184+
paths := strings.Split(namePath, ",")
185+
for _, path := range paths {
186+
path = strings.TrimSpace(path)
187+
value := strings.TrimSpace(query.String(path))
188+
if value != "" {
189+
return value
190+
}
191+
}
192+
193+
return ""
194+
}
195+
146196
func getOAuthAuthorizationURL(ctx context.Context, q *query.GetOAuthAuthorizationURL) error {
147197
config, err := getConfig(ctx, q.Provider)
148198
if err != nil {
@@ -161,7 +211,7 @@ func getOAuthAuthorizationURL(ctx context.Context, q *query.GetOAuthAuthorizatio
161211
Redirect: q.Redirect,
162212
Identifier: q.Identifier,
163213
})
164-
214+
165215
if err != nil {
166216
return err
167217
}

app/services/oauth/oauth_test.go

Lines changed: 190 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func TestGetAuthURL_Facebook(t *testing.T) {
5454

5555
err := bus.Dispatch(ctx, authURL)
5656
Expect(err).IsNil()
57-
Expect(authURL.Result).Equals("https://www.facebook.com/v3.2/dialog/oauth?client_id=FB_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2Ffacebook%2Fcallback&response_type=code&scope=public_profile+email&state="+expectedState)
57+
Expect(authURL.Result).Equals("https://www.facebook.com/v3.2/dialog/oauth?client_id=FB_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2Ffacebook%2Fcallback&response_type=code&scope=public_profile+email&state=" + expectedState)
5858
}
5959

6060
func TestGetAuthURL_Google(t *testing.T) {
@@ -76,7 +76,7 @@ func TestGetAuthURL_Google(t *testing.T) {
7676

7777
err := bus.Dispatch(ctx, authURL)
7878
Expect(err).IsNil()
79-
Expect(authURL.Result).Equals("https://accounts.google.com/o/oauth2/v2/auth?client_id=GO_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2Fgoogle%2Fcallback&response_type=code&scope=profile+email&state="+expectedState)
79+
Expect(authURL.Result).Equals("https://accounts.google.com/o/oauth2/v2/auth?client_id=GO_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2Fgoogle%2Fcallback&response_type=code&scope=profile+email&state=" + expectedState)
8080
}
8181

8282
func TestGetAuthURL_GitHub(t *testing.T) {
@@ -98,7 +98,7 @@ func TestGetAuthURL_GitHub(t *testing.T) {
9898

9999
err := bus.Dispatch(ctx, authURL)
100100
Expect(err).IsNil()
101-
Expect(authURL.Result).Equals("https://github.com/login/oauth/authorize?client_id=GH_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2Fgithub%2Fcallback&response_type=code&scope=user%3Aemail&state="+expectedState)
101+
Expect(authURL.Result).Equals("https://github.com/login/oauth/authorize?client_id=GH_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2Fgithub%2Fcallback&response_type=code&scope=user%3Aemail&state=" + expectedState)
102102
}
103103

104104
func TestGetAuthURL_Custom(t *testing.T) {
@@ -131,7 +131,7 @@ func TestGetAuthURL_Custom(t *testing.T) {
131131

132132
err := bus.Dispatch(ctx, authURL)
133133
Expect(err).IsNil()
134-
Expect(authURL.Result).Equals("https://example.org/oauth/authorize?client_id=CU_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2F_custom%2Fcallback&response_type=code&scope=profile+email&state="+expectedState)
134+
Expect(authURL.Result).Equals("https://example.org/oauth/authorize?client_id=CU_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2F_custom%2Fcallback&response_type=code&scope=profile+email&state=" + expectedState)
135135
}
136136

137137
func TestGetAuthURL_Twitch(t *testing.T) {
@@ -164,7 +164,7 @@ func TestGetAuthURL_Twitch(t *testing.T) {
164164

165165
err := bus.Dispatch(ctx, authURL)
166166
Expect(err).IsNil()
167-
Expect(authURL.Result).Equals("https://id.twitch.tv/oauth/authorize?claims=%7B%22userinfo%22%3A%7B%22preferred_username%22%3Anull%2C%22email%22%3Anull%2C%22email_verified%22%3Anull%7D%7D&client_id=CU_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2F_custom%2Fcallback&response_type=code&scope=openid&state="+expectedState)
167+
Expect(authURL.Result).Equals("https://id.twitch.tv/oauth/authorize?claims=%7B%22userinfo%22%3A%7B%22preferred_username%22%3Anull%2C%22email%22%3Anull%2C%22email_verified%22%3Anull%7D%7D&client_id=CU_CL_ID&redirect_uri=http%3A%2F%2Flogin.test.fider.io%3A3000%2Foauth%2F_custom%2Fcallback&response_type=code&scope=openid&state=" + expectedState)
168168
}
169169

170170
func TestParseProfileResponse_AllFields(t *testing.T) {
@@ -456,3 +456,188 @@ func TestCustomOAuth_Disabled(t *testing.T) {
456456
Expect(err).IsNotNil()
457457
Expect(oauthProfile.Result).IsNil()
458458
}
459+
460+
func TestParseOAuthRawProfile_CompositeName(t *testing.T) {
461+
RegisterT(t)
462+
463+
// Initialize the OAuth service
464+
bus.Init(&oauth.Service{})
465+
466+
testCases := []struct {
467+
name string
468+
jsonBody string
469+
jsonNamePath string
470+
expectedName string
471+
expectedEmail string
472+
expectedID string
473+
}{
474+
{
475+
name: "Simple path",
476+
jsonBody: `{"id": "123", "name": "Jon Snow", "email": "jon.snow@got.com"}`,
477+
jsonNamePath: "name",
478+
expectedName: "Jon Snow",
479+
expectedEmail: "jon.snow@got.com",
480+
expectedID: "123",
481+
},
482+
{
483+
name: "Fallback path - first exists",
484+
jsonBody: `{"id": "123", "name": "Jon Snow", "login": "jonsnow", "email": "jon.snow@got.com"}`,
485+
jsonNamePath: "name, login",
486+
expectedName: "Jon Snow",
487+
expectedEmail: "jon.snow@got.com",
488+
expectedID: "123",
489+
},
490+
{
491+
name: "Fallback path - first missing",
492+
jsonBody: `{"id": "123", "login": "jonsnow", "email": "jon.snow@got.com"}`,
493+
jsonNamePath: "name, login",
494+
expectedName: "jonsnow",
495+
expectedEmail: "jon.snow@got.com",
496+
expectedID: "123",
497+
},
498+
{
499+
name: "Composite path with space",
500+
jsonBody: `{"id": "123", "firstname": "Jon", "lastname": "Snow", "email": "jon.snow@got.com"}`,
501+
jsonNamePath: "firstname + ' ' + lastname",
502+
expectedName: "Jon Snow",
503+
expectedEmail: "jon.snow@got.com",
504+
expectedID: "123",
505+
},
506+
{
507+
name: "Composite path with comma",
508+
jsonBody: `{"id": "123", "firstname": "Jon", "lastname": "Snow", "email": "jon.snow@got.com"}`,
509+
jsonNamePath: "lastname + ', ' + firstname",
510+
expectedName: "Snow, Jon",
511+
expectedEmail: "jon.snow@got.com",
512+
expectedID: "123",
513+
},
514+
{
515+
name: "Composite path with missing field",
516+
jsonBody: `{"id": "123", "firstname": "Jon", "email": "jon.snow@got.com"}`,
517+
jsonNamePath: "firstname + ' ' + lastname",
518+
expectedName: "Jon", // lastname is missing, so only firstname is used
519+
expectedEmail: "jon.snow@got.com",
520+
expectedID: "123",
521+
},
522+
{
523+
name: "Nested JSON path",
524+
jsonBody: `{"id": "123", "profile": {"name": {"first": "Jon", "last": "Snow"}}, "email": "jon.snow@got.com"}`,
525+
jsonNamePath: "profile.name.first + ' ' + profile.name.last",
526+
expectedName: "Jon Snow",
527+
expectedEmail: "jon.snow@got.com",
528+
expectedID: "123",
529+
},
530+
{
531+
name: "Empty name with email fallback",
532+
jsonBody: `{"id": "123", "email": "jon.snow@got.com"}`,
533+
jsonNamePath: "name",
534+
expectedName: "jon.snow", // Should use part before @ in email
535+
expectedEmail: "jon.snow@got.com",
536+
expectedID: "123",
537+
},
538+
{
539+
name: "Empty name and email",
540+
jsonBody: `{"id": "123"}`,
541+
jsonNamePath: "name",
542+
expectedName: "Anonymous", // Should use "Anonymous"
543+
expectedEmail: "",
544+
expectedID: "123",
545+
},
546+
}
547+
548+
for _, tc := range testCases {
549+
t.Run(tc.name, func(t *testing.T) {
550+
// Create a mock config for this test case
551+
mockConfig := &entity.OAuthConfig{
552+
JSONUserIDPath: "id",
553+
JSONUserNamePath: tc.jsonNamePath,
554+
JSONUserEmailPath: "email",
555+
}
556+
557+
// Register a mock handler for getConfig
558+
bus.AddHandler(func(ctx context.Context, q *query.GetCustomOAuthConfigByProvider) error {
559+
q.Result = mockConfig
560+
return nil
561+
})
562+
563+
// Create the parse command
564+
parseCmd := &cmd.ParseOAuthRawProfile{
565+
Provider: "test_provider",
566+
Body: tc.jsonBody,
567+
}
568+
569+
// Execute the command
570+
err := bus.Dispatch(context.Background(), parseCmd)
571+
Expect(err).IsNil()
572+
573+
// Verify the result
574+
profile := parseCmd.Result
575+
Expect(profile).IsNotNil()
576+
Expect(profile.ID).Equals(tc.expectedID)
577+
Expect(profile.Name).Equals(tc.expectedName)
578+
Expect(profile.Email).Equals(tc.expectedEmail)
579+
})
580+
}
581+
}
582+
583+
// Test for invalid inputs
584+
func TestParseOAuthRawProfile_InvalidInputs(t *testing.T) {
585+
RegisterT(t)
586+
587+
// Initialize the OAuth service
588+
bus.Init(&oauth.Service{})
589+
590+
testCases := []struct {
591+
name string
592+
jsonBody string
593+
jsonNamePath string
594+
expectedError bool
595+
}{
596+
{
597+
name: "Missing ID",
598+
jsonBody: `{"name": "Jon Snow", "email": "jon.snow@got.com"}`,
599+
jsonNamePath: "name",
600+
expectedError: true,
601+
},
602+
{
603+
name: "Invalid email",
604+
jsonBody: `{"id": "123", "name": "Jon Snow", "email": "not-an-email"}`,
605+
jsonNamePath: "name",
606+
expectedError: false, // Should not error, but email should be empty
607+
},
608+
}
609+
610+
for _, tc := range testCases {
611+
t.Run(tc.name, func(t *testing.T) {
612+
// Create a mock config for this test case
613+
mockConfig := &entity.OAuthConfig{
614+
JSONUserIDPath: "id",
615+
JSONUserNamePath: tc.jsonNamePath,
616+
JSONUserEmailPath: "email",
617+
}
618+
619+
// Register a mock handler for getConfig
620+
bus.AddHandler(func(ctx context.Context, q *query.GetCustomOAuthConfigByProvider) error {
621+
q.Result = mockConfig
622+
return nil
623+
})
624+
625+
// Create the parse command
626+
parseCmd := &cmd.ParseOAuthRawProfile{
627+
Provider: "test_provider",
628+
Body: tc.jsonBody,
629+
}
630+
631+
// Execute the command
632+
err := bus.Dispatch(context.Background(), parseCmd)
633+
634+
if tc.expectedError {
635+
Expect(err).IsNotNil()
636+
} else {
637+
Expect(err).IsNil()
638+
profile := parseCmd.Result
639+
Expect(profile).IsNotNil()
640+
}
641+
})
642+
}
643+
}

public/pages/Administration/components/OAuthForm.tsx

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export const OAuthForm: React.FC<OAuthFormProps> = (props) => {
8686
disabled={!fider.session.user.isAdministrator}
8787
onChange={setDisplayName}
8888
/>
89-
<Field label="Button Preview">
89+
<Field className="flex flex-y" label="Button Preview">
9090
<SocialSignInButton option={{ displayName: displayName || "Button", provider, logoBlobKey, logoURL }} />
9191
</Field>
9292
</div>
@@ -149,6 +149,13 @@ export const OAuthForm: React.FC<OAuthFormProps> = (props) => {
149149
</Input>
150150

151151
<h3 className="text-title mt-8 mb-2">JSON Path</h3>
152+
<p>
153+
Find out more about{" "}
154+
<a rel="noopener" className="text-link" target="_blank" href="https://fider.io/docs/configuring-oauth#configuring-the-json-paths">
155+
configuring the JSON Paths
156+
</a>
157+
.
158+
</p>
152159

153160
<div className="grid grid-cols-3 gap-4">
154161
<Input
@@ -159,10 +166,7 @@ export const OAuthForm: React.FC<OAuthFormProps> = (props) => {
159166
disabled={!fider.session.user.isAdministrator}
160167
onChange={setJSONUserIDPath}
161168
>
162-
<p className="text-muted">
163-
Path to extract User ID from the JSON. This ID <strong>must</strong> be unique within the provider or unexpected side effects might happen. For
164-
example below, the path would be <strong>id</strong>.
165-
</p>
169+
<p className="text-muted">Make sure it&apos;s unique. </p>
166170
</Input>
167171
<Input
168172
field="jsonUserNamePath"
@@ -173,8 +177,7 @@ export const OAuthForm: React.FC<OAuthFormProps> = (props) => {
173177
onChange={setJSONUserNamePath}
174178
>
175179
<p className="text-muted">
176-
Path to extract user Display Name from the JSON. This is optional, but <strong>highly</strong> recommended. For the example below, the path would
177-
be <strong>profile.name</strong>.
180+
Optional, but <strong>highly</strong> recommended.
178181
</p>
179182
</Input>
180183
<Input
@@ -186,29 +189,11 @@ export const OAuthForm: React.FC<OAuthFormProps> = (props) => {
186189
onChange={setJSONUserEmailPath}
187190
>
188191
<p className="text-muted">
189-
Path to extract user Email from the JSON. This is optional, but <strong>highly</strong> recommended. For the example below, the path would be{" "}
190-
<strong>profile.emails[0]</strong>.
192+
Optional, but <strong>highly</strong> recommended.
191193
</p>
192194
</Input>
193195
</div>
194196

195-
<h3 className="text-title mb-2">Example Response</h3>
196-
197-
<pre>
198-
{`{
199-
id: "35235"
200-
title: "Sr. Account Manager",
201-
profile: {
202-
dob: "01/05/2018",
203-
name: "John Doe"
204-
emails: [
205-
"john.doe@company.com"
206-
]
207-
}
208-
}
209-
`}
210-
</pre>
211-
212197
<Field label="Trusted Source">
213198
<Toggle field="isTrusted" active={isTrusted} onToggle={setTrusted} label={isTrusted ? "Yes" : "No"} />
214199
<p className="text-muted mt-1">

0 commit comments

Comments
 (0)