Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

creating a token issuing service using the JWT Profile #674

Closed
2 tasks done
kevinschoonover opened this issue Nov 4, 2024 · 1 comment
Closed
2 tasks done

creating a token issuing service using the JWT Profile #674

kevinschoonover opened this issue Nov 4, 2024 · 1 comment
Labels
auth enhancement New feature or request

Comments

@kevinschoonover
Copy link
Contributor

Preflight Checklist

  • I could not find a solution in the existing issues, docs, nor discussions
  • I have joined the ZITADEL chat

Describe your problem

I am attempting to implement a service that can generate test users and provide the access token / id token to the developer (or other automated service) so they can authenticate as that user. I wanted to try using JWT Profiles since I see them recommended in multiple discussions / issues; however, I wanted to check if my observations are correct that I'm not sure the correct hooks exists today in the library. I am happy to submit a PR to help implement this but wanted to make sure what path y'all wanted to take before going down this rabbit hole.

For comparison, the client credentials endpoint has the following function that allows you to set subject/audience/etc.

func (s *Storage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (op.TokenRequest, error) {
	client, ok := s.clients[clientID]
	if !ok {
		return nil, errors.New("wrong service user or password")
	}
	return &oidc.JWTTokenRequest{
		Subject:  client.id, # can set the subject of the access token
		Audience: []string{clientID}, # can set the audiences
		Scopes:   client.RestrictAdditionalAccessTokenScopes()(scopes), # can modify the scopes of the access token
	}, nil
}

Server Limitations

The JWT Profile on the other hand has ValidateJWTProfileScopes which only allows you to modify the provided scopes

// ValidateJWTProfileScopes implements the op.Storage interface
// it will be called to validate the scopes of a JWT Profile Authorization Grant request
func (s *Storage) ValidateJWTProfileScopes(ctx context.Context, userID string, scopes []string) ([]string, error) {
	allowedScopes := make([]string, 0)
	for _, scope := range scopes {
		if scope == oidc.ScopeOpenID {
			allowedScopes = append(allowedScopes, scope)
		}
	}
	return allowedScopes, nil
}

The token response endpoint also seems to be missing the id token

// by default the access_token is an opaque string, but can be specified by implementing the JWTProfileTokenStorage interface
func CreateJWTTokenResponse(ctx context.Context, tokenRequest TokenRequest, creator TokenCreator) (*oidc.AccessTokenResponse, error) {
	ctx, span := tracer.Start(ctx, "CreateJWTTokenResponse")
	defer span.End()

	// return an opaque token as default to not break current implementations
	tokenType := AccessTokenTypeBearer

	// the current CreateAccessToken function, esp. CreateJWT requires an implementation of an AccessTokenClient
	client := &jwtProfileClient{
		id: tokenRequest.GetSubject(),
	}

	// by implementing the JWTProfileTokenStorage the storage can specify the AccessTokenType to be returned
	tokenStorage, ok := creator.Storage().(JWTProfileTokenStorage)
	if ok {
		var err error
		tokenType, err = tokenStorage.JWTProfileTokenType(ctx, tokenRequest)
		if err != nil {
			return nil, err
		}
	}

	accessToken, _, validity, err := CreateAccessToken(ctx, tokenRequest, tokenType, creator, client, "")
	if err != nil {
		return nil, err
	}
	return &oidc.AccessTokenResponse{
		AccessToken: accessToken,
               // id token missing here
		TokenType:   oidc.BearerToken,
		ExpiresIn:   uint64(validity.Seconds()),
	}, nil
}

Client Limitations

Looking at the NewJWTProfileTokenSource it forces the audience to be the issuer (which in my case if I want to generate a token for my test user for a different service it won't match the issuer)

// NewJWTProfileSource returns an implementation of oauth2.TokenSource
// It will request a token using the OAuth2 JWT Profile Grant,
// therefore sending an `assertion` by singing a JWT with the provided private key.
//
// The passed context is only used for the call to the Discover endpoint.
func NewJWTProfileTokenSource(ctx context.Context, issuer, clientID, keyID string, key []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) {
	signer, err := client.NewSignerFromPrivateKeyByte(key, keyID)
	if err != nil {
		return nil, err
	}
	source := &jwtProfileTokenSource{
		clientID:   clientID,
		audience:   []string{issuer},
		signer:     signer,
		scopes:     scopes,
		httpClient: http.DefaultClient,
	}
	for _, opt := range options {
		opt(source)
	}
	if source.tokenEndpoint == "" {
		config, err := client.Discover(ctx, issuer, source.httpClient)
		if err != nil {
			return nil, err
		}
		source.tokenEndpoint = config.TokenEndpoint
	}
	return source, nil
}

I attempted to change the audience to my service by using

source, err := profile.NewJWTProfileTokenSource(ctx,
		"my api client id",
		"my test user id",
		"jwt key id i generate for the test user",
		"private key i generate for the test user",
		[]strings{"my", "custom", "scopes"}, # seemingly all good here
		profile.WithStaticTokenEndpoint("test", "http://localhost:8080/oauth/token"),
	)

but the server rejects this with 'audience must contain client_id'

Describe your ideal solution

I want to have a machine service that is able to use the JWT Profile to generate access + id tokens for test users that is identical to what a user would get when logging through using other grant types. The machine service needs to be able to set the subject, audience, and scopes of the access token (and preferably also the userinfo that can get picked up via introspection) so that it can return it to the other user / integration test service

Expected Changes

Comparing the JWTProfile handler and ClientCredential handler they have very similar structures but ClientCredentials has validatedRequest, client, err := ValidateClientCredentialsRequest(r.Context(), request, exchanger) which can modify the request; however, JWTProfileScopes only has ValidateJWTProfileScopes(r.Context(), tokenRequest.Issuer, profileRequest.Scope) so it seems like changing this function over to a more generate ValidateJWTProfile would solve this particular problem

// JWTProfile handles the OAuth 2.0 JWT Profile Authorization Grant https://tools.ietf.org/html/rfc7523#section-2.1
func JWTProfile(w http.ResponseWriter, r *http.Request, exchanger JWTAuthorizationGrantExchanger) {
	ctx, span := tracer.Start(r.Context(), "JWTProfile")
	defer span.End()
	r = r.WithContext(ctx)

	profileRequest, err := ParseJWTProfileGrantRequest(r, exchanger.Decoder())
	if err != nil {
		RequestError(w, r, err, exchanger.Logger())
	}

	tokenRequest, err := VerifyJWTAssertion(r.Context(), profileRequest.Assertion, exchanger.JWTProfileVerifier(r.Context()))
	if err != nil {
		RequestError(w, r, err, exchanger.Logger())
		return
	}

	tokenRequest.Scopes, err = exchanger.Storage().ValidateJWTProfileScopes(r.Context(), tokenRequest.Issuer, profileRequest.Scope)
	if err != nil {
		RequestError(w, r, err, exchanger.Logger())
		return
	}
	resp, err := CreateJWTTokenResponse(r.Context(), tokenRequest, exchanger)
	if err != nil {
		RequestError(w, r, err, exchanger.Logger())
		return
	}
	httphelper.MarshalJSON(w, resp)
}
func ClientCredentialsExchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) {
	ctx, span := tracer.Start(r.Context(), "ClientCredentialsExchange")
	defer span.End()
	r = r.WithContext(ctx)

	request, err := ParseClientCredentialsRequest(r, exchanger.Decoder())
	if err != nil {
		RequestError(w, r, err, exchanger.Logger())
	}

	validatedRequest, client, err := ValidateClientCredentialsRequest(r.Context(), request, exchanger)
	if err != nil {
		RequestError(w, r, err, exchanger.Logger())
		return
	}

	resp, err := CreateClientCredentialsTokenResponse(r.Context(), validatedRequest, exchanger, client)
	if err != nil {
		RequestError(w, r, err, exchanger.Logger())
		return
	}

	httphelper.MarshalJSON(w, resp)
}

The client would then need to be modified to allow specifying different audiences which should be fairly trival.

Version

github.com/zitadel/oidc/v3 v3.30.1

Additional Context

No response

@kevinschoonover kevinschoonover added the enhancement New feature or request label Nov 4, 2024
@muhlemmer muhlemmer moved this to 🧐 Investigating in Product Management Nov 4, 2024
@muhlemmer muhlemmer added the auth label Nov 4, 2024
@muhlemmer
Copy link
Collaborator

When you use the Provider / Storage interfaces the business logic is kind of locked and does not allow for your use-case. We implement the standards and your usecase is non-standard.

Instead, I will recommend using the Server interface. It will provide you with the request and you can handle it however you wish.

@muhlemmer muhlemmer closed this as not planned Won't fix, can't repro, duplicate, stale Nov 13, 2024
@github-project-automation github-project-automation bot moved this from 🧐 Investigating to ✅ Done in Product Management Nov 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
auth enhancement New feature or request
Projects
Status: Done
Development

No branches or pull requests

2 participants