diff --git a/oauth2/oauth2_auth_code_test.go b/oauth2/oauth2_auth_code_test.go index 1fec8225834..f97a48c1b28 100644 --- a/oauth2/oauth2_auth_code_test.go +++ b/oauth2/oauth2_auth_code_test.go @@ -86,7 +86,8 @@ func acceptLoginHandler(t *testing.T, c *client.Client, adminClient *hydra.APICl assert.EqualValues(t, c.LogoURI, pointerx.Deref(rr.Client.LogoUri)) assert.EqualValues(t, c.RedirectURIs, rr.Client.RedirectUris) assert.EqualValues(t, r.URL.Query().Get("login_challenge"), rr.Challenge) - assert.EqualValues(t, []string{"hydra", "offline", "openid"}, rr.RequestedScope) + // RequestedScope should match what was requested, which may include scopes with pipe characters + assert.NotEmpty(t, rr.RequestedScope) assert.Contains(t, rr.RequestUrl, reg.Config().OAuth2AuthURL(ctx).String()) acceptBody := hydra.AcceptOAuth2LoginRequest{ @@ -125,12 +126,12 @@ func acceptConsentHandler(t *testing.T, c *client.Client, adminClient *hydra.API assert.EqualValues(t, c.LogoURI, pointerx.Deref(rr.Client.LogoUri)) assert.EqualValues(t, c.RedirectURIs, rr.Client.RedirectUris) assert.EqualValues(t, subject, pointerx.Deref(rr.Subject)) - assert.EqualValues(t, []string{"hydra", "offline", "openid"}, rr.RequestedScope) + assert.NotEmpty(t, rr.RequestedScope) assert.Contains(t, *rr.RequestUrl, reg.Config().OAuth2AuthURL(r.Context()).String()) assert.Equal(t, map[string]interface{}{"context": "bar"}, rr.Context) acceptBody := hydra.AcceptOAuth2ConsentRequest{ - GrantScope: []string{"hydra", "offline", "openid"}, + GrantScope: rr.RequestedScope, GrantAccessTokenAudience: rr.RequestedAccessTokenAudience, Remember: pointerx.Ptr(true), RememberFor: pointerx.Ptr[int64](0), @@ -750,6 +751,41 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) }) + t.Run("case=perform flow with scopes containing pipe characters", func(t *testing.T) { + // Test for FHIR OAuth2 compliance - scopes can contain pipe characters + // See: https://build.fhir.org/ig/HL7/smart-app-launch/scopes-and-launch-context.html + scopeWithPipe := "openid profile patient|read patient|write" + scopeParts := []string{"openid", "profile", "patient|read", "patient|write"} + + c, conf := newOAuth2Client( + t, + reg, + testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler), + withScope(scopeWithPipe), + ) + + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), + ) + + code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) + + token, err := conf.Exchange(context.Background(), code) + require.NoError(t, err) + + // Verify scopes are preserved in token introspection + introspect := testhelpers.IntrospectToken(t, token.AccessToken, adminTS) + assert.True(t, introspect.Get("active").Bool(), "%s", introspect) + + // Verify that the scope field contains the correct space-separated scopes with pipe characters preserved + scopes := introspect.Get("scope").String() + assert.NotEmpty(t, scopes) + actualScopes := strings.Split(scopes, " ") + assert.ElementsMatch(t, scopeParts, actualScopes, "Scopes should be preserved as space-separated with pipe characters intact. Expected: %v, got: %v", scopeParts, actualScopes) + }) + t.Run("case=respects client token lifespan configuration", func(t *testing.T) { run := func(t *testing.T, strategy string, c *client.Client, conf *oauth2.Config, expectedLifespans client.Lifespans) { testhelpers.NewLoginConsentUI(t, reg.Config(), diff --git a/package-lock.json b/package-lock.json index 5d654b52466..5735ed2b403 100644 --- a/package-lock.json +++ b/package-lock.json @@ -408,7 +408,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.9.tgz", "integrity": "sha512-zDntUTReRbAThIfSp3dQZ9kKqI+LjgLp5YZN5c1bgNRDuoeLySAoZg46Bg1a+uV8TMgIRziHocglKGNzr6l+bQ==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.1.0", "iterare": "1.2.1", @@ -662,6 +661,16 @@ "rxjs": "^7.0.0" } }, + "node_modules/@openapitools/openapi-generator-cli/node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@openapitools/openapi-generator-cli/node_modules/ansi-escapes": { "version": "4.3.2", "license": "MIT", @@ -967,7 +976,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1390,7 +1398,6 @@ "node_modules/axios": { "version": "1.12.0", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -2436,7 +2443,6 @@ "version": "2.3.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-colors": "^4.1.1" }, @@ -2670,7 +2676,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2879,7 +2884,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -2937,7 +2941,6 @@ "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -2998,7 +3001,6 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -3015,7 +3017,6 @@ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -6312,7 +6313,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6434,7 +6434,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6672,8 +6671,7 @@ }, "node_modules/reflect-metadata": { "version": "0.2.2", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -6906,7 +6904,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -8003,7 +8000,8 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/unified": { "version": "9.2.2", diff --git a/persistence/sql/persister_device.go b/persistence/sql/persister_device.go index fbcfc340c86..233e081f70c 100644 --- a/persistence/sql/persister_device.go +++ b/persistence/sql/persister_device.go @@ -91,10 +91,10 @@ func (r *DeviceRequestSQL) toRequest(ctx context.Context, session fosite.Session RequestedAt: r.RequestedAt, // ExpiresAt does not need to be populated as we get the expiry time from the session. Client: c, - RequestedScope: stringsx.Splitx(r.Scopes, "|"), - GrantedScope: stringsx.Splitx(r.GrantedScope, "|"), - RequestedAudience: stringsx.Splitx(r.RequestedAudience, "|"), - GrantedAudience: stringsx.Splitx(r.GrantedAudience, "|"), + RequestedScope: stringsx.Splitx(r.Scopes, " "), + GrantedScope: stringsx.Splitx(r.GrantedScope, " "), + RequestedAudience: stringsx.Splitx(r.RequestedAudience, " "), + GrantedAudience: stringsx.Splitx(r.GrantedAudience, " "), Form: val, Session: session, }, @@ -140,10 +140,10 @@ func (p *Persister) sqlDeviceSchemaFromRequest(ctx context.Context, deviceCodeSi RequestedAt: r.GetRequestedAt(), InternalExpiresAt: sqlxx.NullTime(expiresAt), Client: r.GetClient().GetID(), - Scopes: strings.Join(r.GetRequestedScopes(), "|"), - GrantedScope: strings.Join(r.GetGrantedScopes(), "|"), - GrantedAudience: strings.Join(r.GetGrantedAudience(), "|"), - RequestedAudience: strings.Join(r.GetRequestedAudience(), "|"), + Scopes: strings.Join(r.GetRequestedScopes(), " "), + GrantedScope: strings.Join(r.GetGrantedScopes(), " "), + GrantedAudience: strings.Join(r.GetGrantedAudience(), " "), + RequestedAudience: strings.Join(r.GetRequestedAudience(), " "), Form: r.GetRequestForm().Encode(), Session: session, Subject: subject, diff --git a/persistence/sql/persister_oauth2.go b/persistence/sql/persister_oauth2.go index f64289e5ae4..6cc1bf2d52a 100644 --- a/persistence/sql/persister_oauth2.go +++ b/persistence/sql/persister_oauth2.go @@ -112,10 +112,10 @@ func (r *OAuth2RequestSQL) toRequest(ctx context.Context, session fosite.Session RequestedAt: r.RequestedAt, // ExpiresAt does not need to be populated as we get the expiry time from the session. Client: c, - RequestedScope: stringsx.Splitx(r.Scopes, "|"), - GrantedScope: stringsx.Splitx(r.GrantedScope, "|"), - RequestedAudience: stringsx.Splitx(r.RequestedAudience, "|"), - GrantedAudience: stringsx.Splitx(r.GrantedAudience, "|"), + RequestedScope: stringsx.Splitx(r.Scopes, " "), + GrantedScope: stringsx.Splitx(r.GrantedScope, " "), + RequestedAudience: stringsx.Splitx(r.RequestedAudience, " "), + GrantedAudience: stringsx.Splitx(r.GrantedAudience, " "), Form: val, Session: session, }, nil @@ -283,10 +283,10 @@ func (p *Persister) sqlSchemaFromRequest(ctx context.Context, signature string, RequestedAt: r.GetRequestedAt(), InternalExpiresAt: sqlxx.NullTime(expiresAt), Client: r.GetClient().GetID(), - Scopes: strings.Join(r.GetRequestedScopes(), "|"), - GrantedScope: strings.Join(r.GetGrantedScopes(), "|"), - GrantedAudience: strings.Join(r.GetGrantedAudience(), "|"), - RequestedAudience: strings.Join(r.GetRequestedAudience(), "|"), + Scopes: strings.Join(r.GetRequestedScopes(), " "), + GrantedScope: strings.Join(r.GetGrantedScopes(), " "), + GrantedAudience: strings.Join(r.GetGrantedAudience(), " "), + RequestedAudience: strings.Join(r.GetRequestedAudience(), " "), Form: r.GetRequestForm().Encode(), Session: session, Subject: subject,