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
2 changes: 2 additions & 0 deletions api/discovery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ paths:
authorization_response_iss_parameter_supported: true
authorization_grant_profiles_supported:
- "urn:ietf:params:oauth:grant-profile:id-jag"
native_sso_supported: true

/.well-known/openid-configuration:
get:
Expand Down Expand Up @@ -120,6 +121,7 @@ paths:
authorization_response_iss_parameter_supported: true
authorization_grant_profiles_supported:
- "urn:ietf:params:oauth:grant-profile:id-jag"
native_sso_supported: true
"500":
description: Internal server error — metadata could not be generated.

Expand Down
30 changes: 29 additions & 1 deletion api/oauth2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -665,10 +665,30 @@ components:
- urn:ietf:params:oauth:grant-type:token-exchange
- urn:ietf:params:oauth:grant-type:jwt-bearer
- urn:openid:params:grant-type:ciba
description: The OAuth 2.0 grant type.
description: |
The OAuth 2.0 grant type.

Native SSO for Mobile Apps (OpenID Connect Native SSO 1.0): an `authorization_code`
request whose grant included the `device_sso` scope must carry a DPoP proof and returns an
additional `device_secret`; its ID token carries `ds_hash` and `sid`. A token-exchange
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
request may present that `device_secret` as `actor_token` with
`actor_token_type=urn:openid:params:token-type:device-secret` and the ID token as
`subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:id_token`);
`audience` must then equal the issuer, the DPoP key must match the device secret's
`cnf.jkt`, and `scope` must stay within the device secret's grant without `device_sso`.
Errors: `invalid_request`, `invalid_target`, `invalid_scope`, `invalid_grant`,
`unauthorized_client`, `interaction_required`.
client_id:
type: string
description: The client identifier (required when not using HTTP Basic auth).
device_secret:
type: string
description: |
Native SSO for Mobile Apps, refresh_token grant only: the device secret the application
already holds. When the refreshed scopes still include `device_sso`, a valid secret bound
to this client, subject and DPoP key is kept and the refreshed ID token carries its
`ds_hash` and `sid`; a missing or invalid secret is replaced by a newly issued
`device_secret` in the response.
client_secret:
type: string
description: The client secret (required for confidential clients not using HTTP Basic auth).
Expand Down Expand Up @@ -770,6 +790,14 @@ components:
issued_token_type:
type: string
description: The type of the issued token (token exchange only).
device_secret:
type: string
description: |
Native SSO for Mobile Apps: the opaque device secret issued by the authorization-code
grant when the `device_sso` scope was granted, and returned again by the refresh_token
grant while `device_sso` stays in scope (the presented secret when it is still valid,
otherwise a replacement). Sibling applications present it as the `actor_token` of a
token-exchange request.

PARRequest:
type: object
Expand Down
2 changes: 1 addition & 1 deletion backend/cmd/server/servicemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa
tokenValidator, err := oauth.Initialize(mux, actorProvider, authnProvider, jwtService, jweService,
flowExecService, observabilitySvc, runtimeCryptoSvc, ouService, attributeCacheService, authZService,
resourceServerProvider, i18nService, idpService, dpopVerifier,
runtimeStoreProvider, transactioner, revocationEnforcer, revocationSvc, oauthCfg)
runtimeStoreProvider, transactioner, revocationEnforcer, revocationSvc, nil, oauthCfg)
fatalOnError(ctx, logger, err, "Failed to initialize OAuth services")

// Initialized after the OAuth services because credential issuance validates the presented
Expand Down
6 changes: 4 additions & 2 deletions backend/internal/oauth/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func Initialize(
transactioner providers.Transactioner,
enforcementService revocation.EnforcementServiceInterface,
revocationSvc revocation.RevocationServiceInterface,
deviceSecretProvider providers.DeviceSecretProvider,
cfg oauthconfig.Config,
) (tokenservice.TokenValidatorInterface, error) {
jwks.Initialize(mux, runtimeCrypto)
Expand All @@ -63,7 +64,8 @@ func Initialize(
})
resolver := jwksresolver.Initialize(httpClient)
scopeValidator := scope.Initialize()
discoveryService := discovery.Initialize(mux, runtimeCrypto, jweService, cfg)
discoveryService := discovery.Initialize(mux, runtimeCrypto, jweService, cfg,
discovery.WithNativeSSOSupported(deviceSecretProvider != nil))
jtiStore := jti.Initialize(runtimeStore)
// The revocation services are constructed by the service manager, not here: the session service
// needs the same criteria revoker, and it is wired before the OAuth engine. This registers the
Expand Down Expand Up @@ -96,7 +98,7 @@ func Initialize(
grantHandlerProvider := granthandlers.Initialize(
jwtService, oauth2AuthzService, tokenBuilder, tokenValidator,
attributeCacheSvc, ouService, authzService, actorProvider, resourceService,
cibaService, revocationSvc, revocationSvc, cfg)
cibaService, revocationSvc, revocationSvc, deviceSecretProvider, cfg)

token.Initialize(mux, jwtService, actorProvider, authnProvider, grantHandlerProvider,
scopeValidator, observabilitySvc, discoveryService, dpopVerifier, jtiStore, cfg)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
// ValidateAuthorizationRequestParams validates the common authorization request parameters
// shared by both the standard authorize endpoint and the PAR endpoint.
//
// This validates: prompt, grant_type, response_type, PKCE, nonce, and dpop_jkt.
// This validates: prompt, grant_type, response_type, PKCE, nonce, the device_sso scope, and dpop_jkt.
// Callers are responsible for validating client_id and redirect_uri before calling this
// function, since those validations have endpoint-specific error handling semantics
// (e.g., the authorize endpoint must not redirect errors when the redirect_uri is invalid).
Expand Down Expand Up @@ -85,6 +85,13 @@ func ValidateAuthorizationRequestParams(
return constants.ErrorInvalidRequest, "nonce exceeds maximum allowed length"
}

// OpenID Connect Native SSO for Mobile Apps 1.0 §3.4: device_sso asks for a device secret that is
// issued together with an ID token, so it is meaningful only in an OpenID Connect request.
scopes := strings.Fields(params.Get(constants.RequestParamScope))
if slices.Contains(scopes, constants.ScopeDeviceSSO) && !slices.Contains(scopes, constants.ScopeOpenID) {
return constants.ErrorInvalidScope, "The device_sso scope requires the openid scope"
}

if dpopJktParam := params.Get(constants.RequestParamDPoPJkt); dpopJktParam != "" {
if !jws.IsValidJKT(dpopJktParam) {
return constants.ErrorInvalidRequest, "Invalid dpop_jkt parameter"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,25 @@ func (suite *AuthzValidationTestSuite) TestValidateParams_ValidNonce() {
assert.Empty(suite.T(), errMsg)
}

func (suite *AuthzValidationTestSuite) TestValidateParams_DeviceSSOWithoutOpenID_Rejected() {
params := suite.validParams()
params.Set(constants.RequestParamScope, "profile "+constants.ScopeDeviceSSO)

errCode, errMsg := ValidateAuthorizationRequestParams(params, suite.oauthApp, "")

assert.Equal(suite.T(), constants.ErrorInvalidScope, errCode)
assert.Contains(suite.T(), errMsg, "requires the openid scope")
}

func (suite *AuthzValidationTestSuite) TestValidateParams_DeviceSSOWithOpenID_Success() {
params := suite.validParams()
params.Set(constants.RequestParamScope, "openid profile "+constants.ScopeDeviceSSO)

errCode, _ := ValidateAuthorizationRequestParams(params, suite.oauthApp, "")

assert.Empty(suite.T(), errCode)
}

func (suite *AuthzValidationTestSuite) TestValidateParams_PromptLogin_Success() {
params := suite.validParams()
params.Set(constants.RequestParamPrompt, "login")
Expand Down
9 changes: 8 additions & 1 deletion backend/internal/oauth/oauth2/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (
RequestParamSubjectTokenType string = "subject_token_type"
RequestParamActorToken string = "actor_token"
RequestParamActorTokenType string = "actor_token_type"
RequestParamDeviceSecret string = "device_secret"
RequestParamRequestedTokenType string = "requested_token_type"
RequestParamAudience string = "audience"
RequestParamAssertion string = "assertion"
Expand Down Expand Up @@ -152,6 +153,9 @@ const (
// Authorization Grant (draft-ietf-oauth-identity-assertion-authz-grant).
//nolint:gosec // Token type identifier, not a credential
TokenTypeIdentifierIDJAG TokenTypeIdentifier = "urn:ietf:params:oauth:token-type:id-jag"
// TokenTypeIdentifierDeviceSecret is the token type for an OIDC Native SSO device secret.
//nolint:gosec // Token type identifier, not a credential
TokenTypeIdentifierDeviceSecret TokenTypeIdentifier = "urn:openid:params:token-type:device-secret"
)

// supportedTokenTypeIdentifiers is the single source of truth for all supported token type identifiers.
Expand All @@ -161,6 +165,7 @@ var supportedTokenTypeIdentifiers = []TokenTypeIdentifier{
TokenTypeIdentifierIDToken,
TokenTypeIdentifierJWT,
TokenTypeIdentifierIDJAG,
TokenTypeIdentifierDeviceSecret,
}

// IsValid checks if the TokenTypeIdentifier is valid.
Expand Down Expand Up @@ -188,6 +193,7 @@ const (
ErrorAccessDenied string = "access_denied"
ErrorLoginRequired string = "login_required"
ErrorConsentRequired string = "consent_required"
ErrorInteractionRequired string = "interaction_required"
ErrorAccountSelectionRequired string = "account_selection_required"
ErrorInvalidDPoPProof string = "invalid_dpop_proof"
ErrorAuthorizationPending string = "authorization_pending"
Expand Down Expand Up @@ -311,7 +317,8 @@ const (

// Standard OIDC scope names.
const (
ScopeOpenID = "openid"
ScopeOpenID = "openid"
ScopeDeviceSSO = "device_sso"
)

const (
Expand Down
35 changes: 35 additions & 0 deletions backend/internal/oauth/oauth2/discovery/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,38 @@ func (suite *DiscoveryTestSuite) TestOIDCDiscovery_EngineOverridesLandInWellKnow
}

func boolPtr(b bool) *bool { return &b }

func (suite *DiscoveryTestSuite) TestNativeSSOSupported_OmittedByDefault() {
suite.cryptoMock.EXPECT().GetPublicKeys(mock.Anything, providers.PublicKeyFilter{}).
Return([]providers.PublicKeyInfo{{KeyID: "k1", Algorithm: string(cryptolib.AlgorithmRS256)}}, nil)

metadata, err := suite.discoveryService.GetOIDCMetadata(context.Background())
assert.NoError(suite.T(), err)

raw, err := json.Marshal(metadata)
assert.NoError(suite.T(), err)
var doc map[string]any
assert.NoError(suite.T(), json.Unmarshal(raw, &doc))
_, present := doc["native_sso_supported"]
assert.False(suite.T(), present, "native_sso_supported is advertised only with a device secret provider")
}

func (suite *DiscoveryTestSuite) TestNativeSSOSupported_Advertised() {
cryptoMock := cryptomock.NewRuntimeCryptoProviderMock(suite.T())
cryptoMock.EXPECT().GetSupportedSigningAlgorithms().Return(suite.oauthCfg.OAuth.DPoP.AllowedAlgs).Maybe()
cryptoMock.EXPECT().GetSupportedEncryptionAlgorithms().
Return([]string{string(cryptolib.AlgorithmRSAOAEP256)}).Maybe()
cryptoMock.EXPECT().GetPublicKeys(mock.Anything, providers.PublicKeyFilter{}).
Return([]providers.PublicKeyInfo{{KeyID: "k1", Algorithm: string(cryptolib.AlgorithmRS256)}}, nil)
svc := newDiscoveryService(cryptoMock, newTestJWEService(cryptoMock), suite.oauthCfg,
WithNativeSSOSupported(true))

metadata, err := svc.GetOIDCMetadata(context.Background())
assert.NoError(suite.T(), err)

raw, err := json.Marshal(metadata)
assert.NoError(suite.T(), err)
var doc map[string]any
assert.NoError(suite.T(), json.Unmarshal(raw, &doc))
assert.Equal(suite.T(), true, doc["native_sso_supported"])
}
4 changes: 2 additions & 2 deletions backend/internal/oauth/oauth2/discovery/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import (
// Initialize initializes the discovery service and registers its routes
func Initialize(
mux *http.ServeMux, cryptoProvider providers.RuntimeCryptoProvider, jweService jwe.JWEServiceInterface,
cfg oauthconfig.Config,
cfg oauthconfig.Config, opts ...Option,
) DiscoveryServiceInterface {
discoveryService := newDiscoveryService(cryptoProvider, jweService, cfg)
discoveryService := newDiscoveryService(cryptoProvider, jweService, cfg, opts...)
discoveryHandler := newDiscoveryHandler(discoveryService)
registerRoutes(mux, discoveryHandler)
return discoveryService
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/oauth/oauth2/discovery/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ type OIDCProviderMetadata struct {
ClaimsParameterSupported bool `json:"claims_parameter_supported"`
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
AcrValuesSupported []string `json:"acr_values_supported,omitempty"`
// NativeSSOSupported is OpenID Connect Native SSO for Mobile Apps 1.0 §5 metadata.
NativeSSOSupported bool `json:"native_sso_supported,omitempty"`
}
24 changes: 20 additions & 4 deletions backend/internal/oauth/oauth2/discovery/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,35 @@ type DiscoveryServiceInterface interface {

// discoveryService implements DiscoveryServiceInterface
type discoveryService struct {
cfg oauthconfig.Config
cryptoProvider providers.RuntimeCryptoProvider
jweService jwe.JWEServiceInterface
cfg oauthconfig.Config
cryptoProvider providers.RuntimeCryptoProvider
jweService jwe.JWEServiceInterface
nativeSSOSupported bool
}

// Option configures optional discovery metadata.
type Option func(*discoveryService)

// WithNativeSSOSupported sets the native_sso_supported metadata (OpenID Connect Native SSO for
// Mobile Apps 1.0 §5). It is advertised when the engine has a device secret provider.
func WithNativeSSOSupported(supported bool) Option {
return func(ds *discoveryService) { ds.nativeSSOSupported = supported }
}

// newDiscoveryService creates a new discovery service instance
func newDiscoveryService(
cryptoProvider providers.RuntimeCryptoProvider, jweService jwe.JWEServiceInterface, cfg oauthconfig.Config,
opts ...Option,
) DiscoveryServiceInterface {
return &discoveryService{
ds := &discoveryService{
cfg: cfg,
cryptoProvider: cryptoProvider,
jweService: jweService,
}
for _, opt := range opts {
opt(ds)
}
return ds
}

// GetOAuth2AuthorizationServerMetadata returns OAuth 2.0 Authorization Server Metadata
Expand Down Expand Up @@ -102,6 +117,7 @@ func (ds *discoveryService) GetOIDCMetadata(ctx context.Context) (*OIDCProviderM
ClaimsSupported: ds.getAllowedClaims(),
ClaimsParameterSupported: true,
AcrValuesSupported: ds.getSupportedAcrValues(),
NativeSSOSupported: ds.nativeSSOSupported,
}

if ds.cfg.OAuth.Logout.IsEnabled() {
Expand Down
Loading
Loading