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

Add secret-less authentication for CI jobs. #131

Merged
merged 7 commits into from
Sep 27, 2024
Merged

Add secret-less authentication for CI jobs. #131

merged 7 commits into from
Sep 27, 2024

Conversation

plietar
Copy link
Member

@plietar plietar commented Sep 20, 2024

This change allows automated environments, such as Github Actions or Buildkite to authenticate to the Packit API without needing to be manually provisioned with a secret.

The client's environment creates and signs a JWT containing claims describing the job. For example, on Github Actions, GitHub provides a token which includes the organisation name, repository name, type of event, branch name, etc. Packit needs to be configured to trust the 3rd party JWT issuer, with policies specifying what claims are needed.

The client sends the JWT it received from its environment to the Packit /auth/login/jwt endpoint, and in exchange receives a Packit JWT which may be used to access the rest of the API.

@plietar plietar mentioned this pull request Sep 20, 2024
@plietar plietar force-pushed the service-user branch 6 times, most recently from 37f8836 to 7381cec Compare September 23, 2024 15:12
@plietar plietar force-pushed the mrc-5763-oidc branch 7 times, most recently from e5d4e31 to 3703e6e Compare September 24, 2024 16:33
@plietar plietar changed the title OIDC Add secret-less authentication for CI jobs. Sep 24, 2024
@plietar plietar marked this pull request as ready for review September 24, 2024 16:43
@plietar plietar force-pushed the mrc-5763-oidc branch 2 times, most recently from b587ff3 to ca82b18 Compare September 25, 2024 12:40
val tokenDuration: Duration? = null,
)

@ConfigurationProperties(prefix = "auth.external-jwt")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deviates from our current approach we've been using in AppConfig. I needed to do this because of the more complex structure the policies has (it is a list of a class which includes a map and a list as sub-fields).

Eventually I think it would actually be nice to move all configuration to this way of doing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to add these into the applcicaton.properties? like the ones described in auth.md docs as well?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@ConfigurationProperties(prefix = "auth.external-jwt")
@ConfigurationProperties(prefix = "auth.externalJwt")

as per docs? or does it hyphen the camelcase?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to add these into the applcicaton.properties

Because these are much more structured, I don't think it makes sense to use flat string environment variables to pass them in, but instead need to pass them as structured data.

There's a number of ways we can do it, see: https://docs.spring.io/spring-boot/reference/features/external-config.html

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as per docs? or does it hyphen the camelcase?

So Spring is kind of weird and I got errors here if I didn't use kebab case here. It seems to apply some normalization to the input properties, which means that it doesn't actually matter what case people use, and both external-jwt and externalJwt would work.

See https://github.com/spring-projects/spring-boot/wiki/Canonical-properties
And https://github.com/spring-projects/spring-boot/blob/eb7b6a776dd8b01849a0ea35d097b81f7eeec23e/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/InvalidConfigurationPropertyNameFailureAnalyzer.java#L51-L52

Although you are right the docs I've written are inconsistent and use both. I'll switch to just one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it makes sense to use flat string environment variables to pass them in, but instead need to pass them as structured data.

so yeah I thought this structured data is picked up from the application.properties? because how does it know what to set the values to ? i thought you need what you have in your docs as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does get populated from the properties in the Environment, but these can come from any number of sources, not just the built-inapplication.properties.

See https://docs.spring.io/spring-boot/reference/features/external-config.html

The simplest way of doing it is probably through the command line arguments.

@plietar
Copy link
Member Author

plietar commented Sep 25, 2024

The naming for this in other projects is kind of a mess:

I decided to avoid the OIDC name and just stick to JWT, or "External JWT" when it is ambiguous wrt Packit's own tokens. I'm happy to use a more catchy name if we want

@plietar
Copy link
Member Author

plietar commented Sep 25, 2024

This is an example of it being used in practice: https://github.com/plietar/orderly-ci-test/blob/f1987986d5fceeaf7b4cceb9aacf2708ae776f0a/run.R

Copy link
Contributor

@absternator absternator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!!!! looks great and i learn lots just reading it!! just left some comments about code structure

val tokenDuration: Duration? = null,
)

@ConfigurationProperties(prefix = "auth.external-jwt")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to add these into the applcicaton.properties? like the ones described in auth.md docs as well?

api/app/src/main/kotlin/packit/service/JwtLoginService.kt Outdated Show resolved Hide resolved
val tokenDuration: Duration? = null,
)

@ConfigurationProperties(prefix = "auth.external-jwt")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@ConfigurationProperties(prefix = "auth.external-jwt")
@ConfigurationProperties(prefix = "auth.externalJwt")

as per docs? or does it hyphen the camelcase?

@ConfigurationProperties(prefix = "auth.external-jwt")
data class JwtLoginConfig(
val audience: String?,
val policy: List<JwtPolicy> = listOf()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

policies instead of policy?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


val audience: String? = jwtLoginConfig.audience

init {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all the init should probs be in JwtLoginConfig and then it has the policies it can pass.. and thus we can keep the service clean

Copy link
Member Author

@plietar plietar Sep 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I agree. The JwtLoginConfig is a plain data class that just represents the properties. It seems odd that it would be initializing some business logic by creating validators and JWT decoders.

On the other hand I don't know if this type of init {} blocks is common or the right way to do this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that is fair but it seems like it does derive directly from config and doesn't need anything else right? and yeah the init in the service seems a bit odd.. maybe could create a bean with this logic and then directly inject into jwtlogin service?

something like this maybe?

@Configuration
class JwtConfig {
    @Bean
    fun tokenPolicies(jwtLoginConfig: JwtLoginConfig, restOperations: RestOperations): List<TokenPolicy> {
        // init logic
    }
}

then can pass into service straight up

class JwtLoginService(
    val policies: List<TokenPolicy>,
 val jwtIssuer: JwtIssuer,
    val userService: UserService,
)

but if ya disagree feel free to keep your way 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'll keep it this way. The contents of the TokenPolicy is so tightly coupled with the implementation of the service it doesn't really make sense to move it elsewhere. The only way the decoupling makes sense IMO is if we make the TokenPolicy an interface and move all the logic into it, at which point the service is nothing much more than a for-loop. Seems like an unnecessary abstraction, especially given there's only one kind of TokenPolicy.

I've changed it from a lateinit var to a val though, since I realised that works just as well.

@@ -23,23 +39,59 @@ class TokenProvider(val config: AppConfig) : JwtIssuer
const val TOKEN_AUDIENCE = "packit"
}

override fun issue(userPrincipal: UserPrincipal): String
{
inner class Builder(val userPrincipal: UserPrincipal) : JwtBuilder {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the inner class stuff makes a bit messy... could move inner class outside and just pass along the config when calling instantiating class

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

override fun issue(userPrincipal: UserPrincipal): String
{
inner class Builder(val userPrincipal: UserPrincipal) : JwtBuilder {
var duration: Duration = Duration.of(config.authExpiryDays, ChronoUnit.DAYS)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should probs be private

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

private fun issueToken(verifiedToken: Jwt, policy: JwtPolicy): String {
val user = userService.getServiceUser()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also this all seems coupled to a service account... thus would a better name for all files and called be ServiceLogin...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah makes sense

@@ -50,6 +52,31 @@ class LoginController(
return ResponseEntity.ok(token)
}

@PostMapping("/login/jwt")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as per other comment about naming.. maybe should be /login/service

This change allows automated environments, such as Github Actions or
Buildkite to authenticate to the Packit API without needing to be
manually provisioned with a secret.

The client's environment creates and signs a JWT containing claims
describing the job. For example, on Github Actions, GitHub provides a
token which includes the organisation name, repository name, type of
event, branch name, etc. Packit needs to be configured to trust the 3rd
party JWT issuer, with policies specifying what claims are needed.

The client sends the JWT it received from its environment to the Packit
`/auth/login/jwt` endpoint, and in exchange receives a Packit JWT which
may be used to access the rest of the API.
@plietar plietar changed the base branch from service-user to main September 26, 2024 13:21
Copy link

codecov bot commented Sep 26, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 97.22%. Comparing base (223ba28) to head (8f2cb21).
Report is 11 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #131   +/-   ##
=======================================
  Coverage   97.22%   97.22%           
=======================================
  Files         131      131           
  Lines        1225     1225           
  Branches      339      339           
=======================================
  Hits         1191     1191           
  Misses         33       33           
  Partials        1        1           
Flag Coverage Δ
97.22% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Contributor

@absternator absternator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work looks good to me!!! just a couple small comments but feel free to merge after response

@plietar plietar merged commit 6ad0ede into main Sep 27, 2024
6 checks passed
@plietar plietar deleted the mrc-5763-oidc branch September 27, 2024 11:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants