├── .github ├── CODEOWNERS ├── workflows │ ├── auto-merge-safe-deps.yml │ ├── close-bom-if-passing.yml │ ├── cd.yaml │ └── jenkins-security-scan.yml └── dependabot.yml ├── .mvn ├── maven.config └── extensions.xml ├── src ├── main │ ├── resources │ │ ├── io │ │ │ └── jenkins │ │ │ │ └── plugins │ │ │ │ └── oidc_provider │ │ │ │ ├── IdTokenCredentials │ │ │ │ ├── help-audience.html │ │ │ │ ├── help-issuer.html │ │ │ │ └── credentials.jelly │ │ │ │ ├── config │ │ │ │ ├── ClaimTemplate │ │ │ │ │ ├── help-type.html │ │ │ │ │ ├── help-name.html │ │ │ │ │ ├── help-format.html │ │ │ │ │ └── config.jelly │ │ │ │ └── IdTokenConfiguration │ │ │ │ │ ├── help-tokenLifetime.html │ │ │ │ │ ├── help-claimTemplates.html │ │ │ │ │ ├── help-globalClaimTemplates.html │ │ │ │ │ ├── help-buildClaimTemplates.html │ │ │ │ │ └── config.jelly │ │ │ │ ├── IdTokenStringCredentials │ │ │ │ └── help.html │ │ │ │ └── IdTokenFileCredentials │ │ │ │ └── help.html │ │ └── index.jelly │ └── java │ │ └── io │ │ └── jenkins │ │ └── plugins │ │ └── oidc_provider │ │ ├── config │ │ ├── ClaimType.java │ │ ├── StringClaimType.java │ │ ├── BooleanClaimType.java │ │ ├── IntegerClaimType.java │ │ ├── ClaimTemplate.java │ │ └── IdTokenConfiguration.java │ │ ├── IdTokenStringCredentials.java │ │ ├── RootIssuer.java │ │ ├── IdTokenFileCredentials.java │ │ ├── FolderIssuer.java │ │ ├── Issuer.java │ │ ├── Keys.java │ │ └── IdTokenCredentials.java └── test │ ├── resources │ └── io │ │ └── jenkins │ │ └── plugins │ │ └── oidc_provider │ │ ├── jcasc.yaml │ │ └── global.yaml │ └── java │ └── io │ └── jenkins │ └── plugins │ └── oidc_provider │ ├── ConfigurationAsCodeTest.java │ ├── config │ └── IdTokenConfigurationTest.java │ ├── IdTokenFileCredentialsTest.java │ ├── IdTokenStringCredentialsTest.java │ ├── FolderIssuerTest.java │ ├── KeysTest.java │ └── IdTokenCredentialsTest.java ├── Jenkinsfile ├── .gitignore ├── demo └── aws │ ├── README.md │ └── run.sh ├── LICENSE.md ├── pom.xml └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jenkinsci/oidc-provider-plugin-developers 2 | -------------------------------------------------------------------------------- /.mvn/maven.config: -------------------------------------------------------------------------------- 1 | -Pconsume-incrementals 2 | -Pmight-produce-incrementals 3 | -Dchangelist.format=%d.v%s 4 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/IdTokenCredentials/help-audience.html: -------------------------------------------------------------------------------- 1 |
2 | Optional audience claim for the id token. 3 |
4 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | buildPlugin( 2 | useContainerAgent: true, 3 | configurations: [ 4 | [platform: 'linux', jdk: 17], 5 | [platform: 'windows', jdk: 21], 6 | ]) 7 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/ClaimTemplate/help-type.html: -------------------------------------------------------------------------------- 1 |
2 | How this text should be parsed as a JSON object. 3 |
4 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/IdTokenConfiguration/help-tokenLifetime.html: -------------------------------------------------------------------------------- 1 |
2 | The time in seconds the issued id_token is valid for. 3 |
4 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/IdTokenStringCredentials/help.html: -------------------------------------------------------------------------------- 1 |
2 | Supplies an OpenID Connect id token (JWT) with claims about the current build. 3 |
4 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/IdTokenFileCredentials/help.html: -------------------------------------------------------------------------------- 1 |
2 | Supplies an OpenID Connect id token (JWT) with claims about the current build as a temporary file. 3 |
4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | 3 | # mvn hpi:run 4 | work 5 | 6 | # IntelliJ IDEA project files 7 | *.iml 8 | *.iws 9 | *.ipr 10 | .idea 11 | 12 | # Eclipse project files 13 | .settings 14 | .classpath 15 | .project 16 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/ClaimTemplate/help-name.html: -------------------------------------------------------------------------------- 1 |
2 | Name of the id token (JWT) claim. 3 | Typically a short lowercase string. 4 | Note that sub must be defined on every id token. 5 |
6 | -------------------------------------------------------------------------------- /src/main/resources/index.jelly: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | Allows Jenkins to act as an OpenID Connect provider and issue identity tokens to builds that can be used for keyless authentication with other services. 5 |
6 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/IdTokenConfiguration/help-claimTemplates.html: -------------------------------------------------------------------------------- 1 |
2 | Claims that will be added to all id tokens, whether scoped to a build or global. 3 | May use constant values or ${JENKINS_URL} but not other variables. 4 |
5 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/IdTokenConfiguration/help-globalClaimTemplates.html: -------------------------------------------------------------------------------- 1 |
2 | Claims that will be added to global id tokens but not those scoped to a build. 3 | May use constant values or ${JENKINS_URL} but not other variables. 4 |
5 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/IdTokenConfiguration/help-buildClaimTemplates.html: -------------------------------------------------------------------------------- 1 |
2 | Claims that will be added to id tokens scoped to a build, but not global ones. 3 | May use constant values or any environment variables defined in builds, such as ${JOB_NAME}. 4 |
5 | -------------------------------------------------------------------------------- /src/test/resources/io/jenkins/plugins/oidc_provider/jcasc.yaml: -------------------------------------------------------------------------------- 1 | credentials: 2 | system: 3 | domainCredentials: 4 | - credentials: 5 | - idToken: 6 | id: my-jwt-1 7 | scope: GLOBAL 8 | audience: wherever.net 9 | - idTokenFile: 10 | id: my-jwt-2 11 | scope: GLOBAL 12 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge-safe-deps.yml: -------------------------------------------------------------------------------- 1 | name: Automatically approve and merge safe dependency updates 2 | on: 3 | - pull_request_target 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | jobs: 8 | auto-merge-safe-deps: 9 | uses: jenkins-infra/github-reusable-workflows/.github/workflows/auto-merge-safe-deps.yml@v1 10 | -------------------------------------------------------------------------------- /.github/workflows/close-bom-if-passing.yml: -------------------------------------------------------------------------------- 1 | name: Close BOM update PR if passing 2 | on: 3 | check_run: 4 | types: 5 | - completed 6 | permissions: 7 | contents: read 8 | pull-requests: write 9 | jobs: 10 | close-bom-if-passing: 11 | uses: jenkins-infra/github-reusable-workflows/.github/workflows/close-bom-if-passing.yml@v1 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "maven" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /demo/aws/README.md: -------------------------------------------------------------------------------- 1 | Allows Jenkins running in GKE to authenticate to AWS and access an S3 bucket. 2 | 3 | Tools required beyond typical Linux commands: 4 | * `helm` 5 | * `kubectl` 6 | * `aws` (preconfigured with an account to which you have reasonable access) 7 | * `gcloud` (ditto) 8 | * `jq` 9 | * `openssl` 10 | 11 | Run: 12 | 13 | ```bash 14 | bash run.sh 15 | ``` 16 | 17 | and note the message about a cleanup script. 18 | -------------------------------------------------------------------------------- /.mvn/extensions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | io.jenkins.tools.incrementals 4 | git-changelist-maven-extension 5 | 1.13 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/ClaimTemplate/help-format.html: -------------------------------------------------------------------------------- 1 |
2 | The format for the claim value, with $VAR or ${VAR} substitutions. 3 | JENKINS_URL is always defined; 4 | in builds, see environment variable reference. 5 | Example: jenkins:${BRANCH_NAME}:${BUILD_NUMBER} 6 |
7 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/IdTokenCredentials/help-issuer.html: -------------------------------------------------------------------------------- 1 |
2 | Alternate issuer URL. 3 | If specified, you must be prepared to serve .well-known/openid-configuration and jwks 4 | from that location as application/json documents. 5 | Jenkins will continue to maintain the private key, 6 | but beware that unauthorized modification of either document could allow id tokens to be forged. 7 |
8 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | # Note: additional setup is required, see https://www.jenkins.io/redirect/continuous-delivery-of-plugins 2 | 3 | name: cd 4 | on: 5 | workflow_dispatch: 6 | check_run: 7 | types: 8 | - completed 9 | 10 | jobs: 11 | maven-cd: 12 | uses: jenkins-infra/github-reusable-workflows/.github/workflows/maven-cd.yml@v1 13 | secrets: 14 | MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} 15 | MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} 16 | -------------------------------------------------------------------------------- /src/test/resources/io/jenkins/plugins/oidc_provider/global.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | idToken: 3 | tokenLifetime: 60 4 | claimTemplates: 5 | - name: ok 6 | format: "true" 7 | type: boolean 8 | globalClaimTemplates: 9 | - name: sub 10 | format: jenkins 11 | type: string 12 | buildClaimTemplates: 13 | - name: sub 14 | format: ^${JOB_NAME} 15 | type: string 16 | - name: num 17 | format: ^${BUILD_NUMBER} 18 | type: integer 19 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/IdTokenCredentials/credentials.jelly: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/jenkins-security-scan.yml: -------------------------------------------------------------------------------- 1 | name: Jenkins Security Scan 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [ opened, synchronize, reopened ] 9 | workflow_dispatch: 10 | 11 | permissions: 12 | security-events: write 13 | contents: read 14 | actions: read 15 | 16 | jobs: 17 | security-scan: 18 | uses: jenkins-infra/jenkins-security-scan/.github/workflows/jenkins-security-scan.yaml@v2 19 | with: 20 | java-cache: 'maven' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate. 21 | # java-version: 21 # Optionally specify what version of Java to set up for the build, or remove to use a recent default. 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2022 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/config/ClaimType.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider.config; 26 | 27 | import hudson.model.AbstractDescribableImpl; 28 | 29 | public abstract class ClaimType extends AbstractDescribableImpl { 30 | 31 | public abstract Object parse(String text); 32 | 33 | } 34 | 35 | // could add e.g. STRING_ARRAY (e.g., split by spaces) or JSON if desired 36 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/ClaimTemplate/config.jelly: -------------------------------------------------------------------------------- 1 | 2 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/config/StringClaimType.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider.config; 26 | 27 | import hudson.Extension; 28 | import hudson.model.Descriptor; 29 | import org.kohsuke.stapler.DataBoundConstructor; 30 | 31 | public final class StringClaimType extends ClaimType { 32 | 33 | @DataBoundConstructor public StringClaimType() {} 34 | 35 | @Override public Object parse(String text) { 36 | return text; 37 | } 38 | 39 | @Extension public static final class DescriptorImpl extends Descriptor { 40 | 41 | @Override public String getDisplayName() { 42 | return "string"; 43 | } 44 | 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/config/BooleanClaimType.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider.config; 26 | 27 | import hudson.Extension; 28 | import hudson.model.Descriptor; 29 | import org.kohsuke.stapler.DataBoundConstructor; 30 | 31 | public final class BooleanClaimType extends ClaimType { 32 | 33 | @DataBoundConstructor public BooleanClaimType() {} 34 | 35 | @Override public Object parse(String text) { 36 | return Boolean.valueOf(text); 37 | } 38 | 39 | @Extension public static final class DescriptorImpl extends Descriptor { 40 | 41 | @Override public String getDisplayName() { 42 | return "boolean"; 43 | } 44 | 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/config/IntegerClaimType.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider.config; 26 | 27 | import hudson.Extension; 28 | import hudson.model.Descriptor; 29 | import org.kohsuke.stapler.DataBoundConstructor; 30 | 31 | public final class IntegerClaimType extends ClaimType { 32 | 33 | @DataBoundConstructor public IntegerClaimType() {} 34 | 35 | @Override public Object parse(String text) { 36 | return Integer.valueOf(text); 37 | } 38 | 39 | @Extension public static final class DescriptorImpl extends Descriptor { 40 | 41 | @Override public String getDisplayName() { 42 | return "integer"; 43 | } 44 | 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/IdTokenStringCredentials.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import com.cloudbees.plugins.credentials.CredentialsScope; 28 | import hudson.Extension; 29 | import hudson.util.Secret; 30 | import java.security.KeyPair; 31 | import org.jenkinsci.Symbol; 32 | import org.jenkinsci.plugins.plaincredentials.StringCredentials; 33 | import org.kohsuke.stapler.DataBoundConstructor; 34 | 35 | /** 36 | * Supplies an id token to a build. 37 | */ 38 | public final class IdTokenStringCredentials extends IdTokenCredentials implements StringCredentials { 39 | 40 | private static final long serialVersionUID = 1; 41 | 42 | @DataBoundConstructor public IdTokenStringCredentials(CredentialsScope scope, String id, String description) { 43 | super(scope, id, description); 44 | } 45 | 46 | private IdTokenStringCredentials(CredentialsScope scope, String id, String description, KeyPair kp, Secret privateKey) { 47 | super(scope, id, description, kp, privateKey); 48 | } 49 | 50 | @Override public Secret getSecret() { 51 | return Secret.fromString(token()); 52 | } 53 | 54 | @Override protected IdTokenCredentials clone(KeyPair kp, Secret privateKey) { 55 | return new IdTokenStringCredentials(getScope(), getId(), getDescription(), kp, privateKey); 56 | } 57 | 58 | @Symbol("idToken") 59 | @Extension public static class DescriptorImpl extends IdTokenCredentialsDescriptor { 60 | 61 | @Override public String getDisplayName() { 62 | return "OpenID Connect id token"; 63 | } 64 | 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/RootIssuer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import edu.umd.cs.findbugs.annotations.NonNull; 28 | import hudson.Extension; 29 | import hudson.model.Item; 30 | import hudson.model.ModelObject; 31 | import hudson.model.Run; 32 | import java.util.Collection; 33 | import java.util.Collections; 34 | import jenkins.model.Jenkins; 35 | import org.kohsuke.stapler.StaplerRequest2; 36 | import org.springframework.security.access.AccessDeniedException; 37 | 38 | /** 39 | * Issuer scoped to Jenkins root with global credentials. 40 | */ 41 | @Extension public final class RootIssuer extends Issuer implements Issuer.Factory { 42 | 43 | @Override public Issuer forUri(@NonNull String prefix) { 44 | return prefix.isEmpty() ? this : null; 45 | } 46 | 47 | @NonNull 48 | @Override protected ModelObject context() { 49 | return Jenkins.get(); 50 | } 51 | 52 | @NonNull 53 | @Override protected String uri() { 54 | return ""; 55 | } 56 | 57 | @Override protected void checkExtendedReadPermission() throws AccessDeniedException { 58 | Jenkins.get().checkPermission(Jenkins.MANAGE); 59 | } 60 | 61 | @NonNull 62 | @Override public Collection forContext(@NonNull Run context) { 63 | return Collections.singleton(this); 64 | } 65 | 66 | @Override public Issuer forConfig(@NonNull StaplerRequest2 req) { 67 | // TODO or unconditionally return this, but register at a lower number than FolderIssuer? 68 | return req.findAncestorObject(Item.class) == null ? this : null; 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/main/resources/io/jenkins/plugins/oidc_provider/config/IdTokenConfiguration/config.jelly: -------------------------------------------------------------------------------- 1 | 2 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 | 39 |
40 |
41 |
42 |
43 | 44 | 45 | 46 |
47 | 48 |
49 |
50 |
51 |
52 | 53 | 54 | 55 |
56 | 57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/IdTokenFileCredentials.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import com.cloudbees.plugins.credentials.CredentialsScope; 28 | import hudson.Extension; 29 | import hudson.util.Secret; 30 | import java.io.ByteArrayInputStream; 31 | import java.io.IOException; 32 | import java.io.InputStream; 33 | import java.nio.charset.StandardCharsets; 34 | import java.security.KeyPair; 35 | import org.jenkinsci.Symbol; 36 | import org.jenkinsci.plugins.plaincredentials.FileCredentials; 37 | import org.kohsuke.stapler.DataBoundConstructor; 38 | 39 | /** 40 | * Supplies an id token to a build as a file. 41 | */ 42 | public final class IdTokenFileCredentials extends IdTokenCredentials implements FileCredentials { 43 | 44 | private static final long serialVersionUID = 1; 45 | 46 | @DataBoundConstructor public IdTokenFileCredentials(CredentialsScope scope, String id, String description) { 47 | super(scope, id, description); 48 | } 49 | 50 | private IdTokenFileCredentials(CredentialsScope scope, String id, String description, KeyPair kp, Secret privateKey) { 51 | super(scope, id, description, kp, privateKey); 52 | } 53 | 54 | @Override public String getFileName() { 55 | return "id_token"; 56 | } 57 | 58 | @Override public InputStream getContent() throws IOException { 59 | return new ByteArrayInputStream(token().getBytes(StandardCharsets.UTF_8)); 60 | } 61 | 62 | @Override protected IdTokenCredentials clone(KeyPair kp, Secret privateKey) { 63 | return new IdTokenFileCredentials(getScope(), getId(), getDescription(), kp, privateKey); 64 | } 65 | 66 | @Symbol("idTokenFile") 67 | @Extension public static class DescriptorImpl extends IdTokenCredentialsDescriptor { 68 | 69 | @Override public String getDisplayName() { 70 | return "OpenID Connect id token as file"; 71 | } 72 | 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/config/ClaimTemplate.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider.config; 26 | 27 | import edu.umd.cs.findbugs.annotations.NonNull; 28 | import hudson.Extension; 29 | import hudson.Util; 30 | import hudson.model.AbstractDescribableImpl; 31 | import hudson.model.Descriptor; 32 | import hudson.util.FormValidation; 33 | import io.jenkins.plugins.oidc_provider.IdTokenCredentials; 34 | import java.util.List; 35 | import java.util.stream.Collectors; 36 | import jenkins.model.Jenkins; 37 | import org.kohsuke.accmod.Restricted; 38 | import org.kohsuke.accmod.restrictions.NoExternalUse; 39 | import org.kohsuke.stapler.DataBoundConstructor; 40 | import org.kohsuke.stapler.QueryParameter; 41 | 42 | public final class ClaimTemplate extends AbstractDescribableImpl { 43 | 44 | public final @NonNull String name; 45 | public final @NonNull String format; 46 | public final @NonNull ClaimType type; 47 | 48 | @DataBoundConstructor public ClaimTemplate(String name, String format, ClaimType type) { 49 | this.name = name; 50 | this.format = format; 51 | this.type = type; 52 | } 53 | 54 | @Restricted(NoExternalUse.class) 55 | public String xmlForm() { 56 | return Jenkins.XSTREAM2.toXML(this); 57 | } 58 | 59 | @Restricted(NoExternalUse.class) 60 | public static List xmlForm(List claimTemplates) { 61 | return claimTemplates.stream().map(ct -> Jenkins.XSTREAM2.toXML(ct)).collect(Collectors.toList()); 62 | } 63 | 64 | @Extension public static final class DescriptorImpl extends Descriptor { 65 | 66 | public ClaimType getDefaultType() { 67 | return new StringClaimType(); 68 | } 69 | 70 | public FormValidation doCheckName(@QueryParameter String value) { 71 | if (Util.fixEmpty(value) == null) { 72 | return FormValidation.error("You must specify a claim name."); 73 | } else if (IdTokenCredentials.STANDARD_CLAIMS.contains(value)) { 74 | return FormValidation.error("You must not specify this standard claim."); 75 | } else { 76 | return FormValidation.ok(); 77 | } 78 | } 79 | 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/test/java/io/jenkins/plugins/oidc_provider/ConfigurationAsCodeTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import com.cloudbees.plugins.credentials.CredentialsProvider; 28 | import com.cloudbees.plugins.credentials.CredentialsScope; 29 | import io.jenkins.plugins.casc.misc.ConfiguredWithCode; 30 | import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule; 31 | import io.jenkins.plugins.casc.misc.junit.jupiter.WithJenkinsConfiguredWithCode; 32 | import io.jenkins.plugins.oidc_provider.config.BooleanClaimType; 33 | import io.jenkins.plugins.oidc_provider.config.ClaimTemplate; 34 | import io.jenkins.plugins.oidc_provider.config.IdTokenConfiguration; 35 | import io.jenkins.plugins.oidc_provider.config.IntegerClaimType; 36 | import io.jenkins.plugins.oidc_provider.config.StringClaimType; 37 | import java.util.Arrays; 38 | import java.util.Collections; 39 | import static org.hamcrest.Matchers.*; 40 | import static org.junit.jupiter.api.Assertions.assertEquals; 41 | import static org.hamcrest.MatcherAssert.assertThat; 42 | 43 | import org.junit.jupiter.api.Test; 44 | 45 | @WithJenkinsConfiguredWithCode 46 | class ConfigurationAsCodeTest { 47 | 48 | @ConfiguredWithCode("jcasc.yaml") 49 | @Test 50 | void basics(JenkinsConfiguredWithCodeRule r) { 51 | IdTokenStringCredentials c1 = CredentialsProvider.lookupCredentialsInItemGroup(IdTokenStringCredentials.class, r.jenkins, null, Collections.emptyList()).get(0); 52 | assertThat(c1.getId(), is("my-jwt-1")); 53 | assertThat(c1.getScope(), is(CredentialsScope.GLOBAL)); 54 | assertThat(c1.getAudience(), is("wherever.net")); 55 | IdTokenFileCredentials c2 = CredentialsProvider.lookupCredentialsInItemGroup(IdTokenFileCredentials.class, r.jenkins, null, Collections.emptyList()).get(0); 56 | assertThat(c2.getId(), is("my-jwt-2")); 57 | assertThat(c2.getAudience(), is(nullValue())); 58 | } 59 | 60 | @ConfiguredWithCode("global.yaml") 61 | @Test 62 | void globalConfiguration(JenkinsConfiguredWithCodeRule r) { 63 | IdTokenConfiguration cfg = IdTokenConfiguration.get(); 64 | assertEquals(60, cfg.getTokenLifetime()); 65 | assertEquals(ClaimTemplate.xmlForm(Collections.singletonList(new ClaimTemplate("ok", "true", new BooleanClaimType()))), 66 | ClaimTemplate.xmlForm(cfg.getClaimTemplates())); 67 | assertEquals(ClaimTemplate.xmlForm(Collections.singletonList(new ClaimTemplate("sub", "jenkins", new StringClaimType()))), 68 | ClaimTemplate.xmlForm(cfg.getGlobalClaimTemplates())); 69 | assertEquals(ClaimTemplate.xmlForm(Arrays.asList(new ClaimTemplate("sub", "${JOB_NAME}", new StringClaimType()), new ClaimTemplate("num", "${BUILD_NUMBER}", new IntegerClaimType()))), 70 | ClaimTemplate.xmlForm(cfg.getBuildClaimTemplates())); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/FolderIssuer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import edu.umd.cs.findbugs.annotations.NonNull; 28 | import hudson.Extension; 29 | import hudson.Util; 30 | import hudson.model.AbstractItem; 31 | import hudson.model.Item; 32 | import hudson.model.ItemGroup; 33 | import hudson.model.ModelObject; 34 | import hudson.model.Run; 35 | import java.net.URI; 36 | import java.util.ArrayList; 37 | import java.util.Collection; 38 | import java.util.List; 39 | import java.util.stream.Collectors; 40 | import java.util.stream.Stream; 41 | import jenkins.model.Jenkins; 42 | import org.kohsuke.stapler.StaplerRequest2; 43 | import org.springframework.security.access.AccessDeniedException; 44 | 45 | /** 46 | * Issuer scoped to a folder with credentials defined (directly) there. 47 | */ 48 | public final class FolderIssuer extends Issuer { 49 | 50 | private final ItemGroup folder; 51 | 52 | private FolderIssuer(ItemGroup folder) { 53 | this.folder = folder; 54 | } 55 | 56 | @NonNull 57 | @Override protected ModelObject context() { 58 | return folder; 59 | } 60 | 61 | /** 62 | * Usually the same as {@link AbstractItem#getUrl} (with leading rather than trailing slash) 63 | * but ignores “current” view as well as unusual {@link ItemGroup#getUrlChildPrefix}s. 64 | * (In practice there are no overrides of the latter whose children could be folders.) 65 | */ 66 | @NonNull 67 | @Override protected String uri() { 68 | return Stream.of(folder.getFullName().split("/")).map(Util::rawEncode).collect(Collectors.joining("/job/", "/job/", "")); 69 | } 70 | 71 | @Override protected void checkExtendedReadPermission() throws AccessDeniedException { 72 | ((Item) folder).checkPermission(Item.READ); 73 | } 74 | 75 | @Extension public static final class Factory implements Issuer.Factory { 76 | 77 | @Override public Issuer forUri(@NonNull String uri) { 78 | if (uri.matches("(/job/[^/]+)+")) { 79 | Item folder = Jenkins.get().getItemByFullName(URI.create(uri.substring(5).replace("/job/", "/")).getPath()); 80 | if (folder instanceof ItemGroup) { 81 | return new FolderIssuer((ItemGroup) folder); 82 | } 83 | } 84 | return null; 85 | } 86 | 87 | @NonNull 88 | @Override public Collection forContext(@NonNull Run context) { 89 | List issuers = new ArrayList<>(); 90 | for (ItemGroup folder = context.getParent().getParent(); folder instanceof Item; folder = ((Item) folder).getParent()) { 91 | issuers.add(new FolderIssuer(folder)); 92 | } 93 | return issuers; 94 | } 95 | 96 | @Override public Issuer forConfig(@NonNull StaplerRequest2 req) { 97 | ItemGroup folder = req.findAncestorObject(ItemGroup.class); 98 | return folder instanceof Item ? new FolderIssuer(folder) : null; 99 | } 100 | 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/test/java/io/jenkins/plugins/oidc_provider/config/IdTokenConfigurationTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider.config; 26 | 27 | import java.util.Arrays; 28 | import java.util.Collections; 29 | 30 | import org.junit.jupiter.api.Test; 31 | import org.junit.jupiter.api.extension.RegisterExtension; 32 | import org.jvnet.hudson.test.junit.jupiter.JenkinsSessionExtension; 33 | 34 | import static org.junit.jupiter.api.Assertions.assertEquals; 35 | 36 | class IdTokenConfigurationTest { 37 | 38 | @RegisterExtension 39 | private final JenkinsSessionExtension rr = new JenkinsSessionExtension(); 40 | 41 | @Test 42 | void gui() throws Throwable { 43 | rr.then(r -> { 44 | IdTokenConfiguration cfg = IdTokenConfiguration.get(); 45 | cfg.setClaimTemplates(Collections.singletonList(new ClaimTemplate("ok", "true", new BooleanClaimType()))); 46 | cfg.setGlobalClaimTemplates(Collections.singletonList(new ClaimTemplate("sub", "jenkins", new StringClaimType()))); 47 | cfg.setBuildClaimTemplates(Arrays.asList(new ClaimTemplate("sub", "${JOB_NAME}", new StringClaimType()), new ClaimTemplate("num", "${BUILD_NUMBER}", new IntegerClaimType()))); 48 | r.submit(r.createWebClient().goTo("configureSecurity").getFormByName("config")); 49 | assertEquals(ClaimTemplate.xmlForm(Collections.singletonList(new ClaimTemplate("ok", "true", new BooleanClaimType()))), 50 | ClaimTemplate.xmlForm(cfg.getClaimTemplates())); 51 | assertEquals(ClaimTemplate.xmlForm(Collections.singletonList(new ClaimTemplate("sub", "jenkins", new StringClaimType()))), 52 | ClaimTemplate.xmlForm(cfg.getGlobalClaimTemplates())); 53 | assertEquals(ClaimTemplate.xmlForm(Arrays.asList(new ClaimTemplate("sub", "${JOB_NAME}", new StringClaimType()), new ClaimTemplate("num", "${BUILD_NUMBER}", new IntegerClaimType()))), 54 | ClaimTemplate.xmlForm(cfg.getBuildClaimTemplates())); 55 | }); 56 | rr.then(r -> { 57 | IdTokenConfiguration cfg = IdTokenConfiguration.get(); 58 | assertEquals(ClaimTemplate.xmlForm(Collections.singletonList(new ClaimTemplate("ok", "true", new BooleanClaimType()))), 59 | ClaimTemplate.xmlForm(cfg.getClaimTemplates())); 60 | assertEquals(ClaimTemplate.xmlForm(Collections.singletonList(new ClaimTemplate("sub", "jenkins", new StringClaimType()))), 61 | ClaimTemplate.xmlForm(cfg.getGlobalClaimTemplates())); 62 | assertEquals(ClaimTemplate.xmlForm(Arrays.asList(new ClaimTemplate("sub", "${JOB_NAME}", new StringClaimType()), new ClaimTemplate("num", "${BUILD_NUMBER}", new IntegerClaimType()))), 63 | ClaimTemplate.xmlForm(cfg.getBuildClaimTemplates())); 64 | cfg.setClaimTemplates(Collections.emptyList()); 65 | r.submit(r.createWebClient().goTo("configureSecurity").getFormByName("config")); 66 | assertEquals(ClaimTemplate.xmlForm(Collections.emptyList()), 67 | ClaimTemplate.xmlForm(cfg.getClaimTemplates())); 68 | assertEquals(ClaimTemplate.xmlForm(Collections.singletonList(new ClaimTemplate("sub", "jenkins", new StringClaimType()))), 69 | ClaimTemplate.xmlForm(cfg.getGlobalClaimTemplates())); 70 | assertEquals(ClaimTemplate.xmlForm(Arrays.asList(new ClaimTemplate("sub", "${JOB_NAME}", new StringClaimType()), new ClaimTemplate("num", "${BUILD_NUMBER}", new IntegerClaimType()))), 71 | ClaimTemplate.xmlForm(cfg.getBuildClaimTemplates())); 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | org.jenkins-ci.plugins 6 | plugin 7 | 5.2098.v4d48a_c4c68e7 8 | 9 | 10 | io.jenkins.plugins 11 | oidc-provider 12 | ${changelist} 13 | hpi 14 | OpenID Connect Provider Plugin 15 | https://github.com/jenkinsci/${project.artifactId}-plugin 16 | 17 | 18 | MIT License 19 | https://opensource.org/licenses/MIT 20 | 21 | 22 | 23 | scm:git:https://github.com/${gitHubRepo} 24 | scm:git:https://github.com/${gitHubRepo} 25 | ${scmTag} 26 | https://github.com/${gitHubRepo} 27 | 28 | 29 | 999999-SNAPSHOT 30 | 31 | 2.504 32 | ${jenkins.baseline}.3 33 | jenkinsci/${project.artifactId}-plugin 34 | false 35 | true 36 | 37 | 38 | 39 | io.jenkins.plugins 40 | jjwt-api 41 | 42 | 43 | org.jenkins-ci.plugins 44 | plain-credentials 45 | 46 | 47 | org.jenkins-ci.plugins.workflow 48 | workflow-api 49 | 50 | 51 | org.jenkins-ci.plugins 52 | credentials-binding 53 | test 54 | 55 | 56 | org.jenkins-ci.plugins.workflow 57 | workflow-cps 58 | test 59 | 60 | 61 | org.jenkins-ci.plugins.workflow 62 | workflow-job 63 | test 64 | 65 | 66 | org.jenkins-ci.plugins.workflow 67 | workflow-durable-task-step 68 | test 69 | 70 | 71 | org.jenkins-ci.plugins.workflow 72 | workflow-basic-steps 73 | test 74 | 75 | 76 | org.jenkinsci.plugins 77 | pipeline-model-definition 78 | test 79 | 80 | 81 | io.jenkins 82 | configuration-as-code 83 | test 84 | 85 | 86 | io.jenkins.configuration-as-code 87 | test-harness 88 | test 89 | 90 | 91 | 92 | 93 | 94 | io.jenkins.tools.bom 95 | bom-${jenkins.baseline}.x 96 | 5622.vc9c3051619f5 97 | pom 98 | import 99 | 100 | 101 | 102 | 103 | 104 | repo.jenkins-ci.org 105 | https://repo.jenkins-ci.org/public/ 106 | 107 | 108 | 109 | 110 | repo.jenkins-ci.org 111 | https://repo.jenkins-ci.org/public/ 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /src/test/java/io/jenkins/plugins/oidc_provider/IdTokenFileCredentialsTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import com.cloudbees.plugins.credentials.CredentialsProvider; 28 | import com.cloudbees.plugins.credentials.CredentialsScope; 29 | import com.cloudbees.plugins.credentials.domains.Domain; 30 | import hudson.slaves.DumbSlave; 31 | import io.jsonwebtoken.Claims; 32 | import io.jsonwebtoken.Jwts; 33 | import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; 34 | import org.jenkinsci.plugins.workflow.job.WorkflowJob; 35 | import org.jenkinsci.plugins.workflow.job.WorkflowRun; 36 | import org.jenkinsci.plugins.workflow.support.actions.EnvironmentAction; 37 | import static org.junit.jupiter.api.Assertions.assertNotNull; 38 | import static org.junit.jupiter.api.Assertions.assertEquals; 39 | 40 | import org.junit.jupiter.api.BeforeEach; 41 | import org.junit.jupiter.api.Test; 42 | import org.junit.jupiter.api.extension.RegisterExtension; 43 | import org.jvnet.hudson.test.JenkinsRule; 44 | import org.jvnet.hudson.test.junit.jupiter.BuildWatcherExtension; 45 | import org.jvnet.hudson.test.junit.jupiter.WithJenkins; 46 | 47 | @WithJenkins 48 | class IdTokenFileCredentialsTest { 49 | 50 | @SuppressWarnings("unused") 51 | @RegisterExtension 52 | private static final BuildWatcherExtension BUILD_WATCHER = new BuildWatcherExtension(); 53 | 54 | private JenkinsRule r; 55 | 56 | @BeforeEach 57 | void beforeEach(JenkinsRule rule) { 58 | r = rule; 59 | } 60 | 61 | @Test 62 | void smokes() throws Exception { 63 | IdTokenFileCredentials c = new IdTokenFileCredentials(CredentialsScope.GLOBAL, "test", null); 64 | c.setAudience("https://service/"); 65 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 66 | r.createSlave("remote", null, null); 67 | WorkflowJob p = r.createProject(WorkflowJob.class, "p"); 68 | p.setDefinition(new CpsFlowDefinition( 69 | """ 70 | node('remote') { 71 | withCredentials([file(variable: 'ID_TOKEN_FILE', credentialsId: 'test')]) { 72 | echo(/binding id token file $ID_TOKEN_FILE/) 73 | env.RESULT = readFile ID_TOKEN_FILE 74 | } 75 | }""", true)); 76 | WorkflowRun b = r.buildAndAssertSuccess(p); 77 | r.assertLogContains("binding id token file ****", b); 78 | EnvironmentAction env = b.getAction(EnvironmentAction.class); 79 | assertNotNull(env); 80 | String idToken = env.getEnvironment().get("RESULT"); 81 | assertNotNull(idToken); 82 | Claims claims = Jwts.parserBuilder(). 83 | setSigningKey(c.publicKey()). 84 | build(). 85 | parseClaimsJws(idToken). 86 | getBody(); 87 | assertEquals(r.jenkins.getRootUrl() + "oidc", claims.getIssuer()); 88 | } 89 | 90 | @Test 91 | void declarative() throws Exception { 92 | IdTokenFileCredentials c = new IdTokenFileCredentials(CredentialsScope.GLOBAL, "test", null); 93 | c.setAudience("https://service/"); 94 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 95 | DumbSlave s = r.createSlave("remote", null, null); 96 | WorkflowJob p = r.createProject(WorkflowJob.class, "p"); 97 | p.setDefinition(new CpsFlowDefinition("pipeline {\n" + 98 | " agent {\n" + 99 | " label 'remote'\n" + 100 | " }\n" + 101 | " environment {\n" + 102 | " ID_TOKEN_FILE=credentials('test')\n" + 103 | " }\n" + 104 | " stages {\n" + 105 | " stage('all') {\n" + 106 | " steps {\n" + 107 | " writeFile(file: 'tok', text: readFile(ID_TOKEN_FILE))\n" + // or Linux: sh 'cp $ID_TOKEN_FILE tok' 108 | " }\n" + 109 | " }\n" + 110 | " }\n" + 111 | "}", true)); 112 | WorkflowRun b = r.buildAndAssertSuccess(p); 113 | String idToken = s.getWorkspaceFor(p).child("tok").readToString(); 114 | Claims claims = Jwts.parserBuilder(). 115 | setSigningKey(c.publicKey()). 116 | build(). 117 | parseClaimsJws(idToken). 118 | getBody(); 119 | System.out.println(claims); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/config/IdTokenConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider.config; 26 | 27 | import edu.umd.cs.findbugs.annotations.CheckForNull; 28 | import edu.umd.cs.findbugs.annotations.NonNull; 29 | import hudson.Extension; 30 | import hudson.ExtensionList; 31 | import io.jsonwebtoken.Claims; 32 | import java.util.ArrayList; 33 | import java.util.Arrays; 34 | import java.util.Collections; 35 | import java.util.List; 36 | import jenkins.model.GlobalConfiguration; 37 | import jenkins.model.GlobalConfigurationCategory; 38 | import net.sf.json.JSONObject; 39 | import org.jenkinsci.Symbol; 40 | import org.kohsuke.stapler.DataBoundSetter; 41 | import org.kohsuke.stapler.StaplerRequest2; 42 | 43 | @Symbol("idToken") 44 | @Extension public final class IdTokenConfiguration extends GlobalConfiguration { 45 | 46 | private static final List DEFAULT_CLAIM_TEMPLATES = Collections.emptyList(); 47 | 48 | private static final List DEFAULT_BUILD_CLAIM_TEMPLATES = Collections.unmodifiableList(Arrays.asList(new ClaimTemplate[] { 49 | new ClaimTemplate(Claims.SUBJECT, "${JOB_URL}", new StringClaimType()), 50 | new ClaimTemplate("build_number", "${BUILD_NUMBER}", new IntegerClaimType()) 51 | })); 52 | 53 | private static final List DEFAULT_GLOBAL_CLAIM_TEMPLATES = Collections.singletonList( 54 | new ClaimTemplate(Claims.SUBJECT, "${JENKINS_URL}", new StringClaimType())); 55 | 56 | public static @NonNull IdTokenConfiguration get() { 57 | return ExtensionList.lookupSingleton(IdTokenConfiguration.class); 58 | } 59 | 60 | private int tokenLifetime = 3600; 61 | 62 | private @CheckForNull List claimTemplates; 63 | private @CheckForNull List buildClaimTemplates; 64 | private @CheckForNull List globalClaimTemplates; 65 | 66 | public IdTokenConfiguration() { 67 | load(); 68 | } 69 | 70 | @Override public GlobalConfigurationCategory getCategory() { 71 | return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class); 72 | } 73 | 74 | private static @CheckForNull List defaulted(@CheckForNull List claimTemplates, @NonNull List defaultClaimTemplates) { 75 | if (claimTemplates == null) { 76 | return null; 77 | } 78 | if (ClaimTemplate.xmlForm(claimTemplates).equals(ClaimTemplate.xmlForm(defaultClaimTemplates))) { 79 | return null; 80 | } else { 81 | return new ArrayList<>(claimTemplates); 82 | } 83 | } 84 | 85 | public int getTokenLifetime() { 86 | return tokenLifetime; 87 | } 88 | 89 | @DataBoundSetter public void setTokenLifetime(final int lifetime) { 90 | this.tokenLifetime = lifetime; 91 | } 92 | 93 | public @NonNull List getClaimTemplates() { 94 | return claimTemplates != null ? claimTemplates : DEFAULT_CLAIM_TEMPLATES; 95 | } 96 | 97 | @DataBoundSetter public void setClaimTemplates(@CheckForNull List claimTemplates) { 98 | this.claimTemplates = defaulted(claimTemplates, DEFAULT_CLAIM_TEMPLATES); 99 | save(); 100 | } 101 | 102 | public @NonNull List getBuildClaimTemplates() { 103 | return buildClaimTemplates != null ? buildClaimTemplates : DEFAULT_BUILD_CLAIM_TEMPLATES; 104 | } 105 | 106 | @DataBoundSetter public void setBuildClaimTemplates(@CheckForNull List buildClaimTemplates) { 107 | this.buildClaimTemplates = defaulted(buildClaimTemplates, DEFAULT_BUILD_CLAIM_TEMPLATES); 108 | save(); 109 | } 110 | 111 | public @NonNull List getGlobalClaimTemplates() { 112 | return globalClaimTemplates != null ? globalClaimTemplates : DEFAULT_GLOBAL_CLAIM_TEMPLATES; 113 | } 114 | 115 | @DataBoundSetter public void setGlobalClaimTemplates(@CheckForNull List globalClaimTemplates) { 116 | this.globalClaimTemplates = defaulted(globalClaimTemplates, DEFAULT_GLOBAL_CLAIM_TEMPLATES); 117 | save(); 118 | } 119 | 120 | @Override public boolean configure(StaplerRequest2 req, JSONObject json) throws FormException { 121 | // Allow empty lists to be configured (form binding will simply omit mention of them): 122 | claimTemplates = null; 123 | buildClaimTemplates = null; 124 | globalClaimTemplates = null; 125 | return super.configure(req, json); 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/Issuer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import com.cloudbees.plugins.credentials.Credentials; 28 | import com.cloudbees.plugins.credentials.CredentialsProvider; 29 | import com.cloudbees.plugins.credentials.CredentialsStore; 30 | import com.cloudbees.plugins.credentials.domains.Domain; 31 | import edu.umd.cs.findbugs.annotations.CheckForNull; 32 | import edu.umd.cs.findbugs.annotations.NonNull; 33 | import hudson.ExtensionPoint; 34 | import hudson.model.Item; 35 | import hudson.model.ItemGroup; 36 | import hudson.model.ModelObject; 37 | import hudson.model.Run; 38 | import java.util.Collection; 39 | import java.util.LinkedHashMap; 40 | import java.util.List; 41 | import java.util.Map; 42 | import java.util.logging.Logger; 43 | import jenkins.model.Jenkins; 44 | import org.kohsuke.stapler.StaplerRequest2; 45 | import org.springframework.security.access.AccessDeniedException; 46 | import org.springframework.security.core.Authentication; 47 | 48 | /** 49 | * Representation of an issuer of tokens. 50 | */ 51 | public abstract class Issuer { 52 | 53 | private static final Logger LOGGER = Logger.getLogger(Issuer.class.getName()); 54 | 55 | /** 56 | * The associated object in Jenkins. 57 | */ 58 | protected abstract @NonNull ModelObject context(); 59 | 60 | /** 61 | * Load credentials from this issuer. 62 | * Only credentials defined here will be returned—no inherited credentials, 63 | * unlike {@link CredentialsProvider#lookupStores} 64 | * or {@link CredentialsProvider#lookupCredentialsInItemGroup(Class, ItemGroup, Authentication, List)}. 65 | * @return a possibly empty set of credentials 66 | */ 67 | public final @NonNull Collection credentials() { 68 | Map credentials = new LinkedHashMap<>(); 69 | for (CredentialsProvider p : CredentialsProvider.enabled(context())) { 70 | CredentialsStore store = p.getStore(context()); 71 | if (store != null) { 72 | LOGGER.fine(() -> "found " + store + " for " + context()); 73 | // TODO should we consider other domains? 74 | for (Credentials c : store.getCredentials(Domain.global())) { 75 | if (c instanceof IdTokenCredentials) { 76 | IdTokenCredentials itc = (IdTokenCredentials) c; 77 | credentials.putIfAbsent(itc.getId(), itc); 78 | } 79 | } 80 | } 81 | } 82 | LOGGER.fine(() -> "in " + context() + " found " + credentials.keySet()); 83 | return credentials.values(); 84 | } 85 | 86 | /** 87 | * URI suffix after {@code https://jenkins/oidc}. 88 | * Should match {@link Item#getUrl} or similar methods when applied to {@link #context}, 89 | * except with an initial rather than a trailing slash ({@code /}). 90 | * @return the empty string, or e.g. {@code /path/subpath} 91 | * @see Marker interface for things with URL 92 | */ 93 | protected abstract @NonNull String uri(); 94 | 95 | /** 96 | * Absolute URL of issuer. 97 | * @return e.g. {@code https://jenkins/oidc/path/subpath} 98 | */ 99 | public final String url() { 100 | return Jenkins.get().getRootUrl() + Keys.URL_NAME + uri(); 101 | } 102 | 103 | /** 104 | * Check permision on the {@link #context} to enumerate credentials and get their metadata. 105 | */ 106 | protected abstract void checkExtendedReadPermission() throws AccessDeniedException; 107 | 108 | @Override public String toString() { 109 | return getClass().getSimpleName() + "[" + url() + "]"; 110 | } 111 | 112 | public interface Factory extends ExtensionPoint { 113 | 114 | /** 115 | * Find an issuer by URI suffix. 116 | * @param uri a possible value of {@link #uri} 117 | * @return a corresponding issuer, if recognized 118 | */ 119 | @CheckForNull Issuer forUri(@NonNull String uri); 120 | 121 | /** 122 | * Find issuers which might be applicable to a given build. 123 | * @param context a build context 124 | * @return issuers handled by this factory which might apply to this build, most specific first (possibly empty) 125 | */ 126 | @NonNull Collection forContext(@NonNull Run context); 127 | 128 | /** 129 | * Find an issuer potentially being configured from a certain screen. 130 | * @param req form validation request in a credentials configuration screen 131 | * @return a potential issuer for that location, if valid 132 | * @see StaplerRequest2#findAncestorObject 133 | */ 134 | @CheckForNull Issuer forConfig(@NonNull StaplerRequest2 req); 135 | 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/Keys.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import edu.umd.cs.findbugs.annotations.CheckForNull; 28 | import hudson.Extension; 29 | import hudson.ExtensionList; 30 | import hudson.model.InvisibleAction; 31 | import hudson.model.UnprotectedRootAction; 32 | import hudson.security.ACL; 33 | import hudson.security.ACLContext; 34 | import java.security.interfaces.RSAPublicKey; 35 | import java.util.Base64; 36 | import java.util.logging.Logger; 37 | import net.sf.json.JSONArray; 38 | import net.sf.json.JSONObject; 39 | import org.kohsuke.stapler.HttpResponses; 40 | import org.kohsuke.stapler.StaplerRequest2; 41 | 42 | /** 43 | * Serves OIDC definition and JWKS. 44 | * @see Obtaining OpenID Provider Configuration Information 45 | * @see OpenID Provider Metadata 46 | */ 47 | @Extension public final class Keys extends InvisibleAction implements UnprotectedRootAction { 48 | 49 | private static final Logger LOGGER = Logger.getLogger(Keys.class.getName()); 50 | 51 | static final String URL_NAME = "oidc"; 52 | static final String WELL_KNOWN_OPENID_CONFIGURATION = "/.well-known/openid-configuration"; 53 | static final String JWKS = "/jwks"; 54 | 55 | @Override public String getUrlName() { 56 | return URL_NAME; 57 | } 58 | 59 | public JSONObject doDynamic(StaplerRequest2 req) { 60 | String path = req.getOriginalRestOfPath(); 61 | try (ACLContext context = ACL.as2(ACL.SYSTEM2)) { // both forUri and credentials might check permissions 62 | Issuer i = findIssuer(path, WELL_KNOWN_OPENID_CONFIGURATION); 63 | if (i != null) { 64 | return openidConfiguration(i.url()); 65 | } else { 66 | i = findIssuer(path, JWKS); 67 | if (i != null) { 68 | // pending https://github.com/jwtk/jjwt/issues/236 69 | // compare https://github.com/jenkinsci/blueocean-plugin/blob/1f92e1624287e7588fc89aa5ce4e4147dd00f3d7/blueocean-jwt/src/main/java/io/jenkins/blueocean/auth/jwt/SigningPublicKey.java#L45-L52 70 | JSONArray keys = new JSONArray(); 71 | for (IdTokenCredentials creds : i.credentials()) { 72 | if (creds.getIssuer() != null) { 73 | LOGGER.fine(() -> "declining to serve key for " + creds.getId() + " since it would be served from " + creds.getIssuer()); 74 | continue; 75 | } 76 | keys.element(key(creds)); 77 | } 78 | return new JSONObject().accumulate("keys", keys); 79 | } 80 | } 81 | throw HttpResponses.notFound(); 82 | } 83 | } 84 | 85 | static JSONObject openidConfiguration(String issuer) { 86 | return new JSONObject(). 87 | accumulate("issuer", issuer). 88 | accumulate("jwks_uri", issuer + JWKS). 89 | accumulate("response_types_supported", new JSONArray().element("code")). 90 | accumulate("subject_types_supported", new JSONArray().element("public")). 91 | accumulate("id_token_signing_alg_values_supported", new JSONArray().element("RS256")). 92 | accumulate("authorization_endpoint", "https://unimplemented"). 93 | accumulate("token_endpoint", "https://unimplemented"); 94 | } 95 | 96 | static JSONObject key(IdTokenCredentials creds) { 97 | RSAPublicKey key = creds.publicKey(); 98 | Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); 99 | return new JSONObject(). 100 | accumulate("kid", creds.getId()). 101 | accumulate("kty", "RSA"). 102 | accumulate("alg", "RS256"). 103 | accumulate("use", "sig"). 104 | accumulate("n", encoder.encodeToString(key.getModulus().toByteArray())). 105 | accumulate("e", encoder.encodeToString(key.getPublicExponent().toByteArray())); 106 | } 107 | 108 | /** 109 | * @param path e.g. {@code /path/subpath/jwks} 110 | * @param suffix e.g. {@code /jwks} 111 | */ 112 | private static @CheckForNull Issuer findIssuer(String path, String suffix) { 113 | if (path.endsWith(suffix)) { 114 | String uri = path.substring(0, path.length() - suffix.length()); // e.g. "" or "/path/subpath" 115 | LOGGER.fine(() -> "looking up issuer for " + uri); 116 | for (Issuer.Factory f : ExtensionList.lookup(Issuer.Factory.class)) { 117 | Issuer i = f.forUri(uri); 118 | if (i != null) { 119 | if (!i.uri().equals(uri)) { 120 | LOGGER.warning(() -> i + " was expected to have URI " + uri); 121 | return null; 122 | } 123 | if (i.credentials().stream().noneMatch(c -> c.getIssuer() == null)) { 124 | LOGGER.fine(() -> "found " + i + " but has no credentials with default issuer; not advertising existence of a folder"); 125 | return null; 126 | } 127 | LOGGER.fine(() -> "found " + i); 128 | return i; 129 | } 130 | } 131 | } 132 | return null; 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /src/test/java/io/jenkins/plugins/oidc_provider/IdTokenStringCredentialsTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import com.cloudbees.plugins.credentials.CredentialsProvider; 28 | import com.cloudbees.plugins.credentials.CredentialsScope; 29 | import com.cloudbees.plugins.credentials.domains.Domain; 30 | import io.jsonwebtoken.Claims; 31 | import io.jsonwebtoken.Jwts; 32 | import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; 33 | import org.jenkinsci.plugins.workflow.job.WorkflowJob; 34 | import org.jenkinsci.plugins.workflow.job.WorkflowRun; 35 | import org.jenkinsci.plugins.workflow.support.actions.EnvironmentAction; 36 | import static org.junit.jupiter.api.Assertions.assertNotNull; 37 | import static org.junit.jupiter.api.Assertions.assertEquals; 38 | 39 | import org.junit.jupiter.api.BeforeEach; 40 | import org.junit.jupiter.api.Test; 41 | import org.junit.jupiter.api.extension.RegisterExtension; 42 | import org.jvnet.hudson.test.JenkinsRule; 43 | import org.jvnet.hudson.test.junit.jupiter.BuildWatcherExtension; 44 | import org.jvnet.hudson.test.junit.jupiter.WithJenkins; 45 | 46 | @WithJenkins 47 | class IdTokenStringCredentialsTest { 48 | 49 | @SuppressWarnings("unused") 50 | @RegisterExtension 51 | private static final BuildWatcherExtension BUILD_WATCHER = new BuildWatcherExtension(); 52 | 53 | private JenkinsRule r; 54 | 55 | @BeforeEach 56 | void beforeEach(JenkinsRule rule) { 57 | r = rule; 58 | } 59 | 60 | @Test 61 | void smokes() throws Exception { 62 | IdTokenStringCredentials c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null); 63 | c.setAudience("https://service/"); 64 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 65 | WorkflowJob p = r.createProject(WorkflowJob.class, "p"); 66 | p.setDefinition(new CpsFlowDefinition("withCredentials([string(variable: 'ID_TOKEN', credentialsId: 'test')]) {echo(/binding id token $ID_TOKEN/); env.RESULT = ID_TOKEN}", true)); 67 | WorkflowRun b = r.buildAndAssertSuccess(p); 68 | r.assertLogContains("binding id token ****", b); 69 | EnvironmentAction env = b.getAction(EnvironmentAction.class); 70 | assertNotNull(env); 71 | String idToken = env.getEnvironment().get("RESULT"); 72 | assertNotNull(idToken); 73 | System.out.println(idToken); 74 | Claims claims = Jwts.parserBuilder(). 75 | setSigningKey(c.publicKey()). 76 | build(). 77 | parseClaimsJws(idToken). 78 | getBody(); 79 | System.out.println(claims); 80 | assertEquals(r.jenkins.getRootUrl() + "oidc", claims.getIssuer()); 81 | assertEquals(p.getAbsoluteUrl(), claims.getSubject()); 82 | assertEquals("https://service/", claims.getAudience()); 83 | assertEquals(1, claims.get("build_number", Integer.class).intValue()); 84 | } 85 | 86 | @Test 87 | void declarative() throws Exception { 88 | IdTokenStringCredentials c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null); 89 | c.setAudience("https://service/"); 90 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 91 | WorkflowJob p = r.createProject(WorkflowJob.class, "p"); 92 | p.setDefinition(new CpsFlowDefinition("pipeline {\n" + 93 | " agent any\n" + 94 | " environment {\n" + 95 | " ID_TOKEN=credentials('test')\n" + 96 | " }\n" + 97 | " stages {\n" + 98 | " stage('all') {\n" + 99 | " steps {\n" + 100 | " writeFile file: 'tok', text: ID_TOKEN\n" + // or Linux: sh 'echo -n $ID_TOKEN > tok' 101 | " }\n" + 102 | " }\n" + 103 | " }\n" + 104 | "}", true)); 105 | WorkflowRun b = r.buildAndAssertSuccess(p); 106 | String idToken = r.jenkins.getWorkspaceFor(p).child("tok").readToString(); 107 | Claims claims = Jwts.parserBuilder(). 108 | setSigningKey(c.publicKey()). 109 | build(). 110 | parseClaimsJws(idToken). 111 | getBody(); 112 | System.out.println(claims); 113 | assertEquals(1, claims.get("build_number", Integer.class).intValue()); 114 | } 115 | 116 | @Test 117 | void alternateIssuer() throws Exception { 118 | IdTokenStringCredentials c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null); 119 | c.setIssuer("https://some.issuer"); 120 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 121 | WorkflowJob p = r.createProject(WorkflowJob.class, "p"); 122 | p.setDefinition(new CpsFlowDefinition("withCredentials([string(variable: 'ID_TOKEN', credentialsId: 'test')]) {env.RESULT = ID_TOKEN}", true)); 123 | WorkflowRun b = r.buildAndAssertSuccess(p); 124 | EnvironmentAction env = b.getAction(EnvironmentAction.class); 125 | assertNotNull(env); 126 | String idToken = env.getEnvironment().get("RESULT"); 127 | assertNotNull(idToken); 128 | Claims claims = Jwts.parserBuilder(). 129 | setSigningKey(c.publicKey()). 130 | build(). 131 | parseClaimsJws(idToken). 132 | getBody(); 133 | System.out.println(claims); 134 | assertEquals("https://some.issuer", claims.getIssuer()); 135 | assertEquals(p.getAbsoluteUrl(), claims.getSubject()); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /demo/aws/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | cd $(dirname "$0") 4 | 5 | name=demo-$USER-$RANDOM 6 | echo set -euxo pipefail >/tmp/cleanup-$name.sh 7 | trap "echo '====>' To clean up: bash /tmp/cleanup-$name.sh" EXIT 8 | 9 | aws s3api create-bucket --bucket $name 10 | echo "aws s3 rb --force s3://$name" >>/tmp/cleanup-$name.sh 11 | 12 | gcloud container clusters create $name \ 13 | --machine-type=n1-standard-4 \ 14 | --cluster-version=latest 15 | echo "gcloud -q container clusters delete $name" >>/tmp/cleanup-$name.sh 16 | 17 | helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx 18 | helm repo add jetstack https://charts.jetstack.io 19 | helm repo add jenkins https://charts.jenkins.io 20 | helm repo update 21 | 22 | helm install \ 23 | --wait \ 24 | --namespace ingress-nginx \ 25 | --create-namespace \ 26 | --set controller.ingressClass=nginx \ 27 | --set controller.service.externalTrafficPolicy=Local \ 28 | --version 4.0.18 \ 29 | ingress-nginx \ 30 | ingress-nginx/ingress-nginx 31 | extip= 32 | while [ -z "$extip" ] 33 | do 34 | extip=`kubectl get svc --namespace ingress-nginx ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'` 35 | echo waiting for ingress to be ready 36 | sleep 3 37 | done 38 | host=$name.$extip.nip.io 39 | 40 | helm install --wait \ 41 | --namespace cert-manager --create-namespace \ 42 | --set installCRDs=true \ 43 | --version v1.7.1 \ 44 | cert-manager \ 45 | jetstack/cert-manager 46 | 47 | kubectl create namespace jenkins 48 | kubectl config set-context --current --namespace=jenkins 49 | 50 | kubectl apply -f - <>/tmp/cleanup-$name.sh 82 | 83 | cat >/tmp/trust-policy.json </tmp/permissions-policy.json <>/tmp/cleanup-$name.sh 119 | 120 | cat >/tmp/jenkins.yaml < List getCredentialsInItemGroup(@NonNull Class type, ItemGroup itemGroup, Authentication authentication, @NonNull List domainRequirements) { 128 | return Collections.emptyList(); 129 | } 130 | 131 | @Override 132 | public CredentialsStore getStore(ModelObject object) { 133 | return new CredentialsStore(ExtraProvider.class) { 134 | 135 | @NonNull 136 | @Override 137 | public ModelObject getContext() { 138 | return object; 139 | } 140 | 141 | @Override 142 | public boolean hasPermission2(@NonNull Authentication a, @NonNull Permission permission) { 143 | return true; 144 | } 145 | 146 | @NonNull 147 | @Override 148 | public List getCredentials(@NonNull Domain domain) { 149 | return Collections.emptyList(); 150 | } 151 | 152 | @Override 153 | public boolean addCredentials(@NonNull Domain domain, @NonNull Credentials credentials) throws IOException { 154 | throw new IOException("no"); 155 | } 156 | 157 | @Override 158 | public boolean removeCredentials(@NonNull Domain domain, @NonNull Credentials credentials) throws IOException { 159 | throw new IOException("no"); 160 | } 161 | 162 | @Override 163 | public boolean updateCredentials(@NonNull Domain domain, @NonNull Credentials current, @NonNull Credentials replacement) throws IOException { 164 | throw new IOException("no"); 165 | } 166 | }; 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenID Connect Provider Plugin 2 | 3 | ## Introduction 4 | 5 | This plugin allows Jenkins builds to be issued “id tokens” in a JSON Web Token (JWT) format 6 | according to OpenID Connect (OIDC) Discovery conventions. 7 | The purpose is to permit Jenkins to authenticate keylessly to external systems such as AWS or GCP. 8 | 9 | For example, if you wished to access GCP services (such as to deploy to Cloud Run), 10 | you could create a long-lived static service account key and store this secret inside Jenkins. 11 | But anyone who manages to steal the secret value could quietly access GCP on their own, 12 | so you would need to periodically rotate the secret and institute special controls over its usage. 13 | Some organizations may even prohibit you from creating such static keys at all. 14 | 15 | Or if Jenkins itself were running on GCP (say in a GKE cluster), 16 | you could configure “workload identity” so that the Jenkins agent process is preauthenticated 17 | to be able to use a specific service account. 18 | This is more secure and manageable. 19 | It only works within the one vendor, however. 20 | 21 | Using OIDC, you can instead rely on what is sometimes referred to as “web identity federation”. 22 | Rather than a secret value, what the external service trusts is that 23 | the Jenkins administrator is in control over what is served from the known (HTTPS) URL. 24 | Internally Jenkins maintains an asymmetric cryptographic keypair. 25 | Builds receive a temporary (timestamped) id token signed with the private key; 26 | Jenkins serves the public key (anonymously) for anyone to verify the authenticity of the tokens. 27 | The service might trust any token from Jenkins, 28 | or might match specific “claims” such as the identity of the project or Git branch name. 29 | 30 | As a special case, the service being accessed may in fact be a secret store such as Vault or Conjur. 31 | Then the Jenkins build can use its id token to access the secret store and retrieve a secret, 32 | which _then_ can be used to access something else 33 | such as a database which only supports traditional passwords. 34 | The advantage is that the database password need not be stored in the Jenkins controller 35 | (it would only be used transiently by an agent process), 36 | so administrators can apply audit controls, rotation policies, etc. in a full-featured storage service. 37 | 38 | The [Conjur Secrets plugin](https://plugins.jenkins.io/conjur-credentials/) 39 | uses a similar system, tailored specifically to Conjur. 40 | The [OpenId Connect Authentication plugin](https://plugins.jenkins.io/oic-auth/) 41 | allows OIDC to be used to authenticate users _to_ Jenkins and is completely unrelated to this use case. 42 | 43 | ## Configuring 44 | 45 | Setting up keyless authentication requires a few steps. 46 | 47 | ### Picking an issuer 48 | 49 | First, decide what the “issuer” of the tokens should be. 50 | By default, Jenkins itself will issue tokens. 51 | This is appropriate if it served from an HTTPS URL visible to the Internet 52 | (or at least the relevant vendor service). 53 | 54 | If the service cannot physically access Jenkins, 55 | you may instead designate another issuer URI. 56 | In this case you must find a way to host two small, static JSON files under that URL. 57 | Jenkins will still sign id tokens with its private key; 58 | the public key, which does not normally change, gets served by the alternate issuer. 59 | (The `iss` claim in the id token will also be updated to match.) 60 | 61 | ### Creating Jenkins credentials 62 | 63 | In Jenkins, create one of two types of credentials: 64 | * **OpenID Connect id token** (yields the id token directly as “secret text”) 65 | * **OpenID Connect id token as file** (saves the id token to a temporary file and yields its path) 66 | 67 | The credentials id is recommended for scripted access in your pipelines, or you may let one be chosen at random. 68 | You may enter an audience URI at this time (see below) but it is optional. 69 | 70 | The credential may be created at the Jenkins root, or in a folder. If you leave the field empty, it will create 71 | the credential at the root, typically under the URI `https://YOUR_JENKINS_HOST/oidc` 72 | After saving, click on the **Update** Link to see the generated issuer URI. 73 | If you picked the external issuer option or entered any value in the issuer URI field, 74 | you will be given instructions on what static files to serve from it and their values. 75 | 76 | To rotate the keypair, simply **Update** and re-**Save** (or otherwise recreate) the credentials. 77 | 78 | ### Registering the identity provider 79 | 80 | Refer to service-specific documentation for creating an “identity provider” or “pool” etc. 81 | You will need to enter at least the issuer URI. 82 | You may ask the service to recognize a particular “audience” URI, 83 | or the service may specify an audience you should use. 84 | The service may allow authorization decisions to be made based on various claims: 85 | the `iss` (issuer), 86 | the `aud` (audience), 87 | the `sub` (subject—in this context, by default the URL of a Jenkins job), 88 | or others (currently a Jenkins build number is included by default). 89 | 90 | The service may associate an identity provider with a service account, role, etc. 91 | This is normally how specific privileges for specific objects are granted. 92 | 93 | ### Use id tokens from builds 94 | 95 | When the id token credentials are accessed during a build 96 | (typically via the [Credentials Binding plugin](https://plugins.jenkins.io/credentials-binding/)), 97 | Jenkins will generate a fresh id token scoped to that build with a limited validity. 98 | Refer to service-specific documentation to see how the token can be used to authenticate. 99 | 100 | ### Configuring claims 101 | 102 | If the default claims are not sufficient, you can customize them. 103 | Go to **Manage Jenkins** » **Configure Global Security** 104 | and under **OpenID Connect** edit the **Claim templates…** to your liking. 105 | 106 | Each template represent a claim (JSON property) to be set in id tokens. 107 | You must include at least sub (subject) in the list. 108 | The value may be a fixed string, or it may be use substitutions from build variables. 109 | For example, `jenkins:${BRANCH_NAME}:${BUILD_NUMBER}` might expand to `jenkins:master:123`. 110 | Normally the claim will be set to a **string** but you may choose a **boolean** (`true` or `false`) 111 | or an **integer** if you prefer these types in the JWT. 112 | 113 | You can add claims to all id tokens, those used during builds, 114 | or those used outside of builds (for example by other Jenkins plugins accepting string credentials). 115 | All applicable kinds of claim templates will be merged. 116 | 117 | ## Examples 118 | 119 | Some tested usage examples follow. Please contribute others! 120 | 121 | ### Accessing AWS 122 | 123 | You will need to create a web identity federation provider, 124 | including a role with a trust policy offering `sts:AssumeRoleWithWebIdentity` 125 | and a permissions policy granting specific abilities. 126 | The audience should conventionally be `sts.amazonaws.com`. 127 | AWS requires the TLS certificate fingerprint of the issuer to be saved. 128 | 129 | Here is an example of such trust policy with account `1234567890` and Jenkins instance running on `https://jenkins.acme.com/`, using the default issuer URL, restricting access to a job named `my-job`: 130 | 131 | ```json 132 | { 133 | "Version": "2012-10-17", 134 | "Statement": [ 135 | { 136 | "Effect": "Allow", 137 | "Principal": { 138 | "Federated": "arn:aws:iam::1234567890:oidc-provider/jenkins.acme.com/oidc" 139 | }, 140 | "Action": "sts:AssumeRoleWithWebIdentity", 141 | "Condition": { 142 | "StringEquals": { 143 | "jenkins.acme.com/oidc:aud": "sts.amazonaws.com", 144 | "jenkins.acme.com/oidc:sub": "https://jenkins.acme.com/job/my-job/" 145 | } 146 | } 147 | } 148 | ] 149 | } 150 | ``` 151 | 152 | If you set the environment variable `AWS_ROLE_ARN` 153 | and bind `AWS_WEB_IDENTITY_TOKEN_FILE` to a temporary file containing an id token, 154 | you can run `aws` CLI commands without further ado. 155 | Every time the role is assumed, AWS contacts the issuer to retrieve the public key. 156 | 157 | A fully automated, end-to-end demo is available. 158 | This also demonstrates configuration of Jenkins as code. 159 | See [instructions](demo/aws/README.md). 160 | 161 | ### Accessing GCP 162 | 163 | You will create a workload identity pool and bind it to a service account 164 | (which should have already been created with the desired permissions). 165 | Sketch of setup: 166 | 167 | ```bash 168 | ISSUER=https://jenkins/oidc 169 | PROJECT=12345678 170 | POOL=your-pool-name 171 | PROVIDER=static 172 | SA=some-sa@your-project.iam.gserviceaccount.com 173 | gcloud iam workload-identity-pools create $POOL \ 174 | --location=global 175 | gcloud iam workload-identity-pools providers create-oidc $PROVIDER \ 176 | --workload-identity-pool=$POOL \ 177 | --issuer-uri=$ISSUER \ 178 | --location=global \ 179 | --attribute-mapping=google.subject=assertion.sub 180 | gcloud iam service-accounts add-iam-policy-binding $SA \ 181 | --role=roles/iam.workloadIdentityUser \ 182 | --member="principalSet://iam.googleapis.com/projects/$PROJECT/locations/global/workloadIdentityPools/$POOL/*" 183 | echo audience must be https://iam.googleapis.com/projects/$PROJECT/locations/global/workloadIdentityPools/$POOL/providers/$PROVIDER 184 | ``` 185 | 186 | Using the id token is currently more awkward than from AWS, unfortunately. 187 | Sketch of usage from a build: 188 | 189 | ```groovy 190 | withCredentials([file(variable: 'ID_TOKEN_FILE', credentialsId: 'gcp')]) { 191 | writeFile file: "$WORKSPACE_TMP/creds.json", text: """ 192 | { 193 | "type": "external_account", 194 | "audience": "//iam.googleapis.com/projects/12345678/locations/global/workloadIdentityPools/your-pool-name/providers/static", 195 | "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", 196 | "token_url": "https://sts.googleapis.com/v1/token", 197 | "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/some-sa@your-project.iam.gserviceaccount.com:generateAccessToken", 198 | "credential_source": { 199 | "file": "$ID_TOKEN_FILE", 200 | "format": { 201 | "type": "text" 202 | } 203 | } 204 | } 205 | """ 206 | sh ''' 207 | gcloud auth login --brief --cred-file=$WORKSPACE_TMP/creds.json 208 | gcloud --project your-project run deploy … 209 | ''' 210 | } 211 | ``` 212 | 213 | GCP contacts the issuer periodically (every few minutes) to retrieve the public key, 214 | whether or not the pool is in use. 215 | (Your access log will show the user agent as `google-thirdparty-credentials`.) 216 | GCP seems to tolerate any TLS certificate that can validate against a root chain. 217 | 218 | ### Accessing HashiCorp Vault 219 | 220 | You will enable and configure `jwt` authentication and use a role for a specific pipeline job. 221 | This way access to required secrets can be granted on a job level. 222 | The pipeline will exchange the JWT against a Vault token and then use that token to access a secret. 223 | In this example the ID token (JWT) credential will be created in a folder. 224 | 225 | Assume there is a kv v2 secret `my-secret` with the secret engine mounted at `kv` and a policy 226 | `my-policy` granting read capability to this secret. 227 | 228 | In Jenkins, in folder `oidc-folder`, create an `OpenID Connect id token` credential with ID `id-token`. 229 | Copy the `Issuer URI`. 230 | 231 | In the same folder, create a pipeline job `oidc-job`: 232 | 233 | ```groovy 234 | pipeline { 235 | agent { 236 | kubernetes { 237 | yaml ''' 238 | apiVersion: v1 239 | kind: Pod 240 | spec: 241 | containers: 242 | - name: vault 243 | image: hashicorp/vault 244 | command: 245 | - cat 246 | tty: true 247 | ''' 248 | } 249 | } 250 | 251 | stages { 252 | stage('vault') { 253 | environment { 254 | VAULT_ADDR="" 255 | VAULT_NAMESPACE="" 256 | } 257 | steps { 258 | withCredentials([string(credentialsId: 'id-token', variable: 'IDTOKEN')]) { 259 | container('vault') { 260 | sh 'vault write -field=token auth/jwt/login jwt=${IDTOKEN} > token' 261 | sh 'set +x ; VAULT_TOKEN=$(cat token) vault read -field=data -format=json kv/data/my-secret' 262 | } 263 | } 264 | } 265 | } 266 | } 267 | } 268 | ``` 269 | 270 | Configure Vault: 271 | ```bash 272 | vault auth enable jwt 273 | vault write auth/jwt/role/my-role name=my-role role_type=jwt policies=my-policy \ 274 | bound_subject="https://jenkins/job/oidc-folder/job/oidc-job/" user_claim=sub 275 | vault write auth/jwt/config oidc_discovery_url="" \ 276 | bound_issuer="" default_role=my-role 277 | ``` 278 | 279 | ## References 280 | 281 | Some relevant background reading. Not intended to be exhaustive. 282 | 283 | ### AWS 284 | 285 | * [About web identity federation](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc.html) 286 | 287 | ### GCP 288 | 289 | * [Introductory video](https://youtu.be/4vajaXzHN08) 290 | * [Workload identity federation](https://cloud.google.com/iam/docs/workload-identity-federation) (introduction) 291 | * [Access resources from an OIDC identity provider](https://cloud.google.com/iam/docs/configuring-workload-identity-federation#oidc) (detailed guide) 292 | 293 | ### Security considerations 294 | 295 | * [Tweet re: workload identity vs. “sops”](https://twitter.com/lorenc_dan/status/1420188842703958020) (from a founder/CEO of Chainguard, active in supply-chain security) 296 | * [Article on Codecov credentials leak](https://www.theregister.com/2021/04/19/codecov_warns_of_stolen_credentials/) 297 | 298 | ### Analogous features in other CI systems 299 | 300 | * [GitHub Actions: Secure cloud deployments with OpenID Connect](https://github.blog/changelog/2021-10-27-github-actions-secure-cloud-deployments-with-openid-connect/) 301 | * [Connecting Bitbucket to resources via OIDC](https://support.atlassian.com/bitbucket-cloud/docs/integrate-pipelines-with-resource-servers-using-oidc/) 302 | * [Connecting GitLab to Vault via OIDC](https://docs.gitlab.com/ee/ci/examples/authenticating-with-hashicorp-vault/) 303 | 304 | ### Secret stores accessible ultimately via OIDC 305 | 306 | * [AWS](https://aws.amazon.com/secrets-manager/) 307 | * [GCP](https://cloud.google.com/secret-manager) 308 | * [CyberArk Conjur: OIDC Authenticator](https://docs.cyberark.com/Product-Doc/OnlineHelp/AAM-DAP/Latest/en/Content/OIDC/OIDC.htm) 309 | * [Using JWT/OIDC from Vault](https://www.vaultproject.io/docs/auth/jwt) 310 | 311 | ## LICENSE 312 | 313 | Licensed under MIT, see [LICENSE](LICENSE.md) 314 | -------------------------------------------------------------------------------- /src/test/java/io/jenkins/plugins/oidc_provider/IdTokenCredentialsTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import edu.umd.cs.findbugs.annotations.NonNull; 28 | import io.jenkins.plugins.oidc_provider.config.IdTokenConfiguration; 29 | import io.jenkins.plugins.oidc_provider.config.ClaimTemplate; 30 | import com.cloudbees.hudson.plugins.folder.Folder; 31 | import com.cloudbees.plugins.credentials.CredentialsProvider; 32 | import com.cloudbees.plugins.credentials.CredentialsScope; 33 | import com.cloudbees.plugins.credentials.domains.Domain; 34 | import hudson.EnvVars; 35 | import hudson.model.EnvironmentContributor; 36 | import hudson.model.Job; 37 | import org.htmlunit.html.HtmlAnchor; 38 | import org.htmlunit.html.HtmlForm; 39 | import org.htmlunit.html.HtmlPage; 40 | import hudson.model.Result; 41 | import hudson.model.Run; 42 | import hudson.model.TaskListener; 43 | import io.jenkins.plugins.oidc_provider.config.BooleanClaimType; 44 | import io.jenkins.plugins.oidc_provider.config.IntegerClaimType; 45 | import io.jenkins.plugins.oidc_provider.config.StringClaimType; 46 | import io.jsonwebtoken.Claims; 47 | import io.jsonwebtoken.Jwts; 48 | 49 | import java.math.BigInteger; 50 | import java.time.Instant; 51 | import java.time.temporal.ChronoUnit; 52 | import java.util.Arrays; 53 | import java.util.Collections; 54 | import java.util.List; 55 | import java.util.concurrent.atomic.AtomicReference; 56 | import jenkins.model.Jenkins; 57 | import static jenkins.test.RunMatchers.logContains; 58 | import static org.hamcrest.Matchers.*; 59 | import static org.junit.jupiter.api.Assertions.assertEquals; 60 | import static org.hamcrest.MatcherAssert.assertThat; 61 | import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; 62 | import org.jenkinsci.plugins.workflow.job.WorkflowJob; 63 | import org.jenkinsci.plugins.workflow.job.WorkflowRun; 64 | import org.jenkinsci.plugins.workflow.support.actions.EnvironmentAction; 65 | import static org.junit.jupiter.api.Assertions.assertTrue; 66 | 67 | import org.junit.jupiter.api.Test; 68 | import org.junit.jupiter.api.extension.RegisterExtension; 69 | import org.jvnet.hudson.test.Issue; 70 | import org.jvnet.hudson.test.JenkinsRule; 71 | import org.jvnet.hudson.test.MockAuthorizationStrategy; 72 | import org.jvnet.hudson.test.TestExtension; 73 | import org.jvnet.hudson.test.junit.jupiter.BuildWatcherExtension; 74 | import org.jvnet.hudson.test.junit.jupiter.JenkinsSessionExtension; 75 | 76 | class IdTokenCredentialsTest { 77 | 78 | @SuppressWarnings("unused") 79 | @RegisterExtension 80 | private static final BuildWatcherExtension BUILD_WATCHER = new BuildWatcherExtension(); 81 | 82 | @RegisterExtension 83 | private final JenkinsSessionExtension rr = new JenkinsSessionExtension(); 84 | 85 | @Test 86 | void persistence() throws Throwable { 87 | AtomicReference modulus = new AtomicReference<>(); 88 | rr.then(r -> { 89 | IdTokenStringCredentials c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null); 90 | c.setIssuer("https://issuer"); 91 | c.setAudience("https://audience"); 92 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 93 | modulus.set(c.publicKey().getModulus()); 94 | }); 95 | rr.then(r -> { 96 | List creds = CredentialsProvider.lookupCredentialsInItemGroup(IdTokenStringCredentials.class, r.jenkins, null, Collections.emptyList()); 97 | assertThat(creds, hasSize(1)); 98 | assertThat(creds.get(0).getId(), is("test")); 99 | assertThat(creds.get(0).getIssuer(), is("https://issuer")); 100 | assertThat(creds.get(0).getAudience(), is("https://audience")); 101 | assertThat("private key retained by serialization", creds.get(0).publicKey().getModulus(), is(modulus.get())); 102 | HtmlForm form = r.createWebClient().goTo("credentials/store/system/domain/_/credential/test/update").getFormByName("update"); 103 | form.getInputByName("_.description").setValue("my creds"); 104 | r.submit(form); 105 | creds = CredentialsProvider.lookupCredentialsInItemGroup(IdTokenStringCredentials.class, r.jenkins, null, Collections.emptyList()); 106 | assertThat(creds, hasSize(1)); 107 | assertThat(creds.get(0).getDescription(), is("my creds")); 108 | assertThat("private key rotated by resaving", creds.get(0).publicKey().getModulus(), is(not(modulus.get()))); 109 | creds.get(0).setIssuer(null); 110 | creds.get(0).setAudience(null); 111 | r.submit(r.createWebClient().goTo("credentials/store/system/domain/_/credential/test/update").getFormByName("update")); 112 | creds = CredentialsProvider.lookupCredentialsInItemGroup(IdTokenStringCredentials.class, r.jenkins, null, Collections.emptyList()); 113 | assertThat(creds, hasSize(1)); 114 | assertThat(creds.get(0).getIssuer(), is(nullValue())); 115 | assertThat(creds.get(0).getAudience(), is(nullValue())); 116 | }); 117 | } 118 | 119 | @Test 120 | void checkIssuer() throws Throwable { 121 | rr.then(r -> { 122 | IdTokenStringCredentials c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "ext1", null); 123 | c.setIssuer("https://xxx"); 124 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 125 | Folder dir = r.createProject(Folder.class, "dir"); 126 | c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "ext2", null); 127 | c.setIssuer("https://xxx"); 128 | CredentialsProvider.lookupStores(dir).iterator().next().addCredentials(Domain.global(), c); 129 | r.jenkins.setSecurityRealm(r.createDummySecurityRealm()); 130 | r.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().grant(Jenkins.ADMINISTER).everywhere().toAuthenticated()); 131 | JenkinsRule.WebClient wc = r.createWebClient(); 132 | String descriptorUrl = "descriptorByName/" + IdTokenStringCredentials.class.getName() + "/"; 133 | String folderDescriptorUrl = "job/dir/" + descriptorUrl; 134 | wc.assertFails(descriptorUrl + "checkIssuer?id=ext1&issuer=", 403); 135 | wc.assertFails(folderDescriptorUrl + "checkIssuer?id=ext2&issuer=", 403); 136 | wc.assertFails(descriptorUrl + "jwks?id=ext1&issuer=", 403); 137 | wc.assertFails(folderDescriptorUrl + "jwks?id=ext2&issuer=", 403); 138 | wc.login("admin"); 139 | assertThat(wc.goTo(descriptorUrl + "checkIssuer?id=ext1&issuer=").getWebResponse().getContentAsString(), containsString("/jenkins/oidc")); 140 | assertThat(wc.goTo(folderDescriptorUrl + "checkIssuer?id=ext2&issuer=").getWebResponse().getContentAsString(), containsString("/jenkins/oidc/job/dir")); 141 | assertThat(wc.goTo(descriptorUrl + "checkIssuer?id=ext1&issuer=https://xxx").getWebResponse().getContentAsString(), containsString("https://xxx/jwks")); 142 | HtmlPage message = wc.goTo(folderDescriptorUrl + "checkIssuer?id=ext2&issuer=https://xxx"); 143 | String messageText = message.getWebResponse().getContentAsString(); 144 | System.out.println(messageText); 145 | assertThat(messageText, containsString("https://xxx/jwks")); 146 | for (HtmlAnchor anchor : message.getAnchors()) { 147 | wc.getPage(message.getFullyQualifiedUrl(anchor.getHrefAttribute())); 148 | } 149 | assertThat(wc.goTo(descriptorUrl + "wellKnownOpenidConfiguration?issuer=https://xxx", "application/json").getWebResponse().getContentAsString(), containsString("\"jwks_uri\":\"https://xxx/jwks\"")); 150 | assertThat(wc.goTo(folderDescriptorUrl + "wellKnownOpenidConfiguration?issuer=https://xxx", "application/json").getWebResponse().getContentAsString(), containsString("\"jwks_uri\":\"https://xxx/jwks\"")); 151 | assertThat(wc.goTo(descriptorUrl + "jwks?id=ext1&issuer=https://xxx", "application/json").getWebResponse().getContentAsString(), containsString("\"kid\":\"ext1\"")); 152 | assertThat(wc.goTo(folderDescriptorUrl + "jwks?id=ext2&issuer=https://xxx", "application/json").getWebResponse().getContentAsString(), containsString("\"kid\":\"ext2\"")); 153 | }); 154 | } 155 | 156 | @Test 157 | void tokenLifetime() throws Throwable { 158 | rr.then(r -> { 159 | IdTokenStringCredentials c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null); 160 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 161 | IdTokenConfiguration cfg = IdTokenConfiguration.get(); 162 | cfg.setTokenLifetime(60); 163 | String idToken = c.getSecret().getPlainText(); 164 | System.out.println(idToken); 165 | Claims claims = Jwts.parserBuilder(). 166 | setSigningKey(c.publicKey()). 167 | build(). 168 | parseClaimsJws(idToken). 169 | getBody(); 170 | 171 | assertTrue(Instant.now().plus(61, ChronoUnit.SECONDS).isAfter(claims.getExpiration().toInstant())); 172 | }); 173 | } 174 | 175 | @Test 176 | void customClaims() throws Throwable { 177 | rr.then(r -> { 178 | IdTokenStringCredentials c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null); 179 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 180 | IdTokenConfiguration cfg = IdTokenConfiguration.get(); 181 | cfg.setClaimTemplates(Collections.singletonList(new ClaimTemplate("ok", "true", new BooleanClaimType()))); 182 | cfg.setGlobalClaimTemplates(Collections.singletonList(new ClaimTemplate("sub", "jenkins", new StringClaimType()))); 183 | cfg.setBuildClaimTemplates(Arrays.asList(new ClaimTemplate("sub", "${JOB_NAME}", new StringClaimType()), new ClaimTemplate("num", "${BUILD_NUMBER}", new IntegerClaimType()))); 184 | String idToken = c.getSecret().getPlainText(); 185 | System.out.println(idToken); 186 | Claims claims = Jwts.parserBuilder(). 187 | setSigningKey(c.publicKey()). 188 | build(). 189 | parseClaimsJws(idToken). 190 | getBody(); 191 | System.out.println(claims); 192 | assertEquals(r.jenkins.getRootUrl() + "oidc", claims.getIssuer()); 193 | assertEquals("jenkins", claims.getSubject()); 194 | assertTrue(claims.get("ok", Boolean.class)); 195 | WorkflowJob p = r.createProject(Folder.class, "dir").createProject(WorkflowJob.class, "p"); 196 | p.setDefinition(new CpsFlowDefinition("withCredentials([string(variable: 'TOK', credentialsId: 'test')]) {env.TOK = TOK}", true)); 197 | WorkflowRun b = r.buildAndAssertSuccess(p); 198 | EnvironmentAction env = b.getAction(EnvironmentAction.class); 199 | idToken = env.getEnvironment().get("TOK"); 200 | System.out.println(idToken); 201 | claims = Jwts.parserBuilder(). 202 | setSigningKey(c.publicKey()). 203 | build(). 204 | parseClaimsJws(idToken). 205 | getBody(); 206 | System.out.println(claims); 207 | assertEquals(r.jenkins.getRootUrl() + "oidc", claims.getIssuer()); 208 | assertEquals("dir/p", claims.getSubject()); 209 | assertEquals(1, claims.get("num", Integer.class).intValue()); 210 | assertTrue(claims.get("ok", Boolean.class)); 211 | }); 212 | } 213 | 214 | @Test 215 | void invalidCustomClaims() throws Throwable { 216 | rr.then(r -> { 217 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null)); 218 | WorkflowJob p = r.createProject(WorkflowJob.class, "p"); 219 | p.setDefinition(new CpsFlowDefinition("withCredentials([string(variable: 'TOK', credentialsId: 'test')]) {echo(/should not get $TOK/)}", true)); 220 | IdTokenConfiguration cfg = IdTokenConfiguration.get(); 221 | cfg.setClaimTemplates(Collections.singletonList(new ClaimTemplate("iss", "oops must not be overridden", new StringClaimType()))); 222 | r.assertLogContains("must not specify iss", r.buildAndAssertStatus(Result.FAILURE, p)); 223 | cfg.setClaimTemplates(Collections.emptyList()); 224 | cfg.setBuildClaimTemplates(Collections.singletonList(new ClaimTemplate("stuff", "fine but where is sub?", new StringClaimType()))); 225 | r.assertLogContains("must specify sub", r.buildAndAssertStatus(Result.FAILURE, p)); 226 | }); 227 | } 228 | 229 | @Issue("SECURITY-3574") 230 | @Test 231 | void spoofedClaimsRunLevel() throws Throwable { 232 | rr.then(r -> { 233 | var c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null); 234 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 235 | var p = r.createProject(Folder.class, "dir").createProject(WorkflowJob.class, "p"); 236 | p.setDefinition(new CpsFlowDefinition("withCredentials([string(variable: 'TOK', credentialsId: 'test')]) {env.TOK = TOK}", true)); 237 | var b = r.buildAndAssertSuccess(p); 238 | var idToken = b.getAction(EnvironmentAction.class).getEnvironment().get("TOK"); 239 | System.out.println(idToken); 240 | var claims = Jwts.parserBuilder(). 241 | setSigningKey(c.publicKey()). 242 | build(). 243 | parseClaimsJws(idToken). 244 | getBody(); 245 | System.out.println(claims); 246 | assertEquals(/* p.getAbsoluteUrl() */ "${JOB_URL}", claims.getSubject()); 247 | assertThat(b, logContains("Refusing to consider conflicting values")); 248 | }); 249 | } 250 | 251 | @SuppressWarnings("unused") 252 | @TestExtension("spoofedClaimsRunLevel") 253 | public static final class RunSpoofer extends EnvironmentContributor { 254 | 255 | @Override 256 | public void buildEnvironmentFor(@NonNull Run r, @NonNull EnvVars envs, @NonNull TaskListener listener) { 257 | envs.put("JOB_URL", "https://bogus.com/"); 258 | } 259 | } 260 | 261 | @Issue("SECURITY-3574") 262 | @Test 263 | void spoofedClaimsJobLevel() throws Throwable { 264 | rr.then(r -> { 265 | var c = new IdTokenStringCredentials(CredentialsScope.GLOBAL, "test", null); 266 | CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), c); 267 | var p = r.createProject(Folder.class, "dir").createProject(WorkflowJob.class, "p"); 268 | p.setDefinition(new CpsFlowDefinition("withCredentials([string(variable: 'TOK', credentialsId: 'test')]) {env.TOK = TOK}", true)); 269 | var b = r.buildAndAssertSuccess(p); 270 | var idToken = b.getAction(EnvironmentAction.class).getEnvironment().get("TOK"); 271 | System.out.println(idToken); 272 | var claims = Jwts.parserBuilder(). 273 | setSigningKey(c.publicKey()). 274 | build(). 275 | parseClaimsJws(idToken). 276 | getBody(); 277 | System.out.println(claims); 278 | assertEquals(/* p.getAbsoluteUrl() */ "${JOB_URL}", claims.getSubject()); 279 | assertThat(b, logContains("Refusing to consider conflicting values")); 280 | }); 281 | } 282 | 283 | @SuppressWarnings("unused") 284 | @TestExtension("spoofedClaimsJobLevel") 285 | public static final class JobSpoofer extends EnvironmentContributor { 286 | 287 | @Override 288 | public void buildEnvironmentFor(@NonNull Job j, @NonNull EnvVars envs, @NonNull TaskListener listener) { 289 | envs.put("JOB_URL", "https://bogus.com/"); 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/main/java/io/jenkins/plugins/oidc_provider/IdTokenCredentials.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2022 CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package io.jenkins.plugins.oidc_provider; 26 | 27 | import com.cloudbees.plugins.credentials.Credentials; 28 | import com.cloudbees.plugins.credentials.CredentialsScope; 29 | import com.cloudbees.plugins.credentials.impl.BaseStandardCredentials; 30 | import edu.umd.cs.findbugs.annotations.CheckForNull; 31 | import edu.umd.cs.findbugs.annotations.NonNull; 32 | import hudson.EnvVars; 33 | import hudson.ExtensionList; 34 | import hudson.Util; 35 | import hudson.model.AbstractBuild; 36 | import hudson.model.Computer; 37 | import hudson.model.EnvironmentContributingAction; 38 | import hudson.model.EnvironmentContributor; 39 | import hudson.model.Job; 40 | import hudson.model.Run; 41 | import hudson.model.TaskListener; 42 | import hudson.util.FormValidation; 43 | import hudson.util.LogTaskListener; 44 | import hudson.util.Secret; 45 | import io.jenkins.plugins.oidc_provider.config.ClaimTemplate; 46 | import io.jenkins.plugins.oidc_provider.config.IdTokenConfiguration; 47 | import io.jsonwebtoken.Claims; 48 | import io.jsonwebtoken.JwtBuilder; 49 | import io.jsonwebtoken.Jwts; 50 | import java.io.IOException; 51 | import java.net.URI; 52 | import java.net.URISyntaxException; 53 | import java.security.KeyFactory; 54 | import java.security.KeyPair; 55 | import java.security.KeyPairGenerator; 56 | import java.security.NoSuchAlgorithmException; 57 | import java.security.interfaces.RSAPrivateCrtKey; 58 | import java.security.interfaces.RSAPublicKey; 59 | import java.security.spec.PKCS8EncodedKeySpec; 60 | import java.security.spec.RSAPublicKeySpec; 61 | import java.time.Instant; 62 | import java.time.temporal.ChronoUnit; 63 | import java.util.ArrayList; 64 | import java.util.Arrays; 65 | import java.util.Base64; 66 | import java.util.Collections; 67 | import java.util.Date; 68 | import java.util.HashSet; 69 | import java.util.List; 70 | import java.util.Map; 71 | import java.util.Objects; 72 | import java.util.Set; 73 | import java.util.concurrent.atomic.AtomicBoolean; 74 | import java.util.function.Consumer; 75 | import java.util.logging.Level; 76 | import java.util.logging.Logger; 77 | import java.util.stream.Collectors; 78 | import jenkins.model.Jenkins; 79 | import net.sf.json.JSONArray; 80 | import net.sf.json.JSONObject; 81 | import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; 82 | import org.kohsuke.stapler.DataBoundSetter; 83 | import org.kohsuke.stapler.HttpResponses; 84 | import org.kohsuke.stapler.QueryParameter; 85 | import org.kohsuke.stapler.StaplerRequest2; 86 | 87 | public abstract class IdTokenCredentials extends BaseStandardCredentials { 88 | 89 | private static final Logger LOGGER = Logger.getLogger(IdTokenCredentials.class.getName()); 90 | 91 | private static final long serialVersionUID = 1; 92 | 93 | /** 94 | * Public/private RSA keypair. 95 | * {@link #privateKey} is the persistent form. 96 | */ 97 | private transient KeyPair kp; 98 | 99 | /** 100 | * Encrypted {@link Base64} encoding of RSA private key in {@link RSAPrivateCrtKey} / {@link PKCS8EncodedKeySpec} format. 101 | * The public key is inferred from this to reload {@link #kp}. 102 | */ 103 | private final Secret privateKey; 104 | 105 | private @CheckForNull String issuer; 106 | 107 | private @CheckForNull String audience; 108 | 109 | private transient @CheckForNull Run build; 110 | 111 | protected IdTokenCredentials(CredentialsScope scope, String id, String description) { 112 | this(scope, id, description, generatePrivateKey()); 113 | } 114 | 115 | private static KeyPair generatePrivateKey() { 116 | KeyPairGenerator gen; 117 | try { 118 | gen = KeyPairGenerator.getInstance("RSA"); 119 | } catch (NoSuchAlgorithmException x) { 120 | throw new AssertionError(x); 121 | } 122 | gen.initialize(2048); 123 | return gen.generateKeyPair(); 124 | } 125 | 126 | private IdTokenCredentials(CredentialsScope scope, String id, String description, KeyPair kp) { 127 | this(scope, id, description, kp, serializePrivateKey(kp)); 128 | } 129 | 130 | private static Secret serializePrivateKey(KeyPair kp) { 131 | assert ((RSAPublicKey) kp.getPublic()).getModulus().equals(((RSAPrivateCrtKey) kp.getPrivate()).getModulus()); 132 | return Secret.fromString(Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); 133 | } 134 | 135 | protected IdTokenCredentials(CredentialsScope scope, String id, String description, KeyPair kp, Secret privateKey) { 136 | super(scope, id, description); 137 | this.kp = kp; 138 | this.privateKey = privateKey; 139 | } 140 | 141 | protected Object readResolve() throws Exception { 142 | KeyFactory kf = KeyFactory.getInstance("RSA"); 143 | RSAPrivateCrtKey priv = (RSAPrivateCrtKey) kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey.getPlainText()))); 144 | kp = new KeyPair(kf.generatePublic(new RSAPublicKeySpec(priv.getModulus(), priv.getPublicExponent())), priv); 145 | return this; 146 | } 147 | 148 | public final String getIssuer() { 149 | return issuer; 150 | } 151 | 152 | @DataBoundSetter public final void setIssuer(String issuer) { 153 | this.issuer = Util.fixEmpty(issuer); 154 | } 155 | 156 | public final String getAudience() { 157 | return audience; 158 | } 159 | 160 | @DataBoundSetter public final void setAudience(String audience) { 161 | this.audience = Util.fixEmpty(audience); 162 | } 163 | 164 | protected abstract IdTokenCredentials clone(KeyPair kp, Secret privateKey); 165 | 166 | @Override public final Credentials forRun(Run context) { 167 | IdTokenCredentials clone = clone(kp, privateKey); 168 | clone.issuer = issuer; 169 | clone.audience = audience; 170 | clone.build = context; 171 | return clone; 172 | } 173 | 174 | RSAPublicKey publicKey() { 175 | return (RSAPublicKey) kp.getPublic(); 176 | } 177 | 178 | /** 179 | * Claims that must not be defined by user claim templates, because they have special meanings. 180 | * {@code sub} is treated specially: it must be defined by a claim template. 181 | * @see OpenID Connect list 182 | * @see JWT list 183 | */ 184 | public static final Set STANDARD_CLAIMS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( 185 | Claims.ISSUER, 186 | Claims.AUDIENCE, 187 | Claims.EXPIRATION, 188 | Claims.ISSUED_AT, 189 | "auth_time", 190 | "nonce", 191 | "acr", 192 | "amr", 193 | "azp", 194 | Claims.NOT_BEFORE, 195 | Claims.ID 196 | ))); 197 | 198 | protected final @NonNull String token() { 199 | IdTokenConfiguration cfg = IdTokenConfiguration.get(); 200 | JwtBuilder builder = Jwts.builder(). 201 | setHeaderParam("kid", getId()). 202 | setIssuer(issuer != null ? issuer : findIssuer().url()). 203 | setAudience(audience). 204 | setExpiration(Date.from(Instant.now().plus(cfg.getTokenLifetime(), ChronoUnit.SECONDS))). 205 | setIssuedAt(new Date()); 206 | Map env; 207 | if (build != null) { 208 | try { 209 | TaskListener listener = new LogTaskListener(Logger.getLogger(IdTokenCredentials.class.getName()), Level.INFO); 210 | if (build instanceof FlowExecutionOwner.Executable feoe) { 211 | var feo = feoe.asFlowExecutionOwner(); 212 | if (feo != null) { 213 | listener = feo.getListener(); 214 | } 215 | } 216 | env = getEnvironment(build, listener); 217 | } catch (IOException | InterruptedException x) { 218 | throw new RuntimeException(x); 219 | } 220 | } else { 221 | // EnvVars.masterEnvVars might not be safe to expose 222 | env = Collections.singletonMap("JENKINS_URL", Jenkins.get().getRootUrl()); 223 | } 224 | AtomicBoolean definedSub = new AtomicBoolean(); 225 | Consumer> addClaims = claimTemplates -> { 226 | for (ClaimTemplate t : claimTemplates) { 227 | if (STANDARD_CLAIMS.contains(t.name)) { 228 | throw new SecurityException("An id token claim template must not specify " + t.name); 229 | } else if (t.name.equals(Claims.SUBJECT)) { 230 | definedSub.set(true); 231 | } 232 | builder.claim(t.name, t.type.parse(Util.replaceMacro(t.format, env))); 233 | } 234 | }; 235 | addClaims.accept(cfg.getClaimTemplates()); 236 | if (build != null) { 237 | addClaims.accept(cfg.getBuildClaimTemplates()); 238 | } else { 239 | addClaims.accept(cfg.getGlobalClaimTemplates()); 240 | } 241 | if (!definedSub.get()) { 242 | throw new SecurityException("An id token claim template must specify " + Claims.SUBJECT); 243 | } 244 | return builder. 245 | signWith(kp.getPrivate()). 246 | compact(); 247 | } 248 | 249 | /** 250 | * Safer version of {@link Run#getEnvironment(TaskListener)} (and {@link Job#getEnvironment}) which prevents overrides. 251 | */ 252 | private static EnvVars getEnvironment(Run build, TaskListener listener) throws IOException, InterruptedException { 253 | var envs = new ArrayList(); 254 | var c = Computer.currentComputer(); 255 | if (c != null) { 256 | envs.add(c.getEnvironment()); 257 | envs.add(c.buildEnvironment(listener)); 258 | } 259 | envs.add(new EnvVars("CLASSPATH", "")); 260 | envs.add(build.getCharacteristicEnvVars()); // includes Job.getCharacteristicEnvVars 261 | for (var ec : EnvironmentContributor.all()) { 262 | var env = new EnvVars(); 263 | ec.buildEnvironmentFor(build.getParent(), env, listener); 264 | envs.add(env); 265 | env = new EnvVars(); 266 | ec.buildEnvironmentFor(build, env, listener); 267 | envs.add(env); 268 | } 269 | if (!(build instanceof AbstractBuild)) { 270 | for (var a : build.getActions(EnvironmentContributingAction.class)) { 271 | var env = new EnvVars(); 272 | a.buildEnvironment(build, env); 273 | envs.add(env); 274 | } 275 | } 276 | var merged = new EnvVars(); 277 | envs.stream().flatMap(env -> env.entrySet().stream()).collect(Collectors.groupingBy(Map.Entry::getKey)).entrySet().stream().forEach(entry -> { 278 | var values = entry.getValue().stream().map(Map.Entry::getValue).collect(Collectors.toSet()); 279 | if (values.size() == 1) { 280 | merged.put(entry.getKey(), values.iterator().next()); 281 | } else { 282 | listener.error("Refusing to consider conflicting values " + values + " of " + entry.getKey() + " for " + build); 283 | } 284 | }); 285 | return merged; 286 | } 287 | 288 | protected @NonNull Issuer findIssuer() { 289 | Run context = build; 290 | if (context == null) { 291 | return ExtensionList.lookupSingleton(RootIssuer.class); 292 | } else { 293 | for (Issuer.Factory f : ExtensionList.lookup(Issuer.Factory.class)) { 294 | for (Issuer i : f.forContext(context)) { 295 | if (i.credentials().contains(this)) { 296 | return i; 297 | } 298 | } 299 | } 300 | } 301 | throw new IllegalStateException("Could not find issuer corresponding to " + getId() + " for " + context.getExternalizableId()); 302 | } 303 | 304 | protected static abstract class IdTokenCredentialsDescriptor extends BaseStandardCredentialsDescriptor { 305 | 306 | private static @CheckForNull Issuer issuerFromRequest(@NonNull StaplerRequest2 req) { 307 | Issuer i = ExtensionList.lookup(Issuer.Factory.class).stream().map(f -> f.forConfig(req)).filter(Objects::nonNull).findFirst().orElse(null); 308 | if (i != null) { 309 | i.checkExtendedReadPermission(); 310 | } 311 | return i; 312 | } 313 | 314 | public final FormValidation doCheckIssuer(StaplerRequest2 req, @QueryParameter String id, @QueryParameter String issuer) { 315 | Issuer i = issuerFromRequest(req); 316 | if (Util.fixEmpty(issuer) == null) { 317 | if (i != null) { 318 | return FormValidation.okWithMarkup("Issuer URI: " + Util.escape(i.url()) + ""); 319 | } else { 320 | return FormValidation.warning("Unable to determine the issuer URI"); 321 | } 322 | } else { 323 | try { 324 | URI u = new URI(issuer); 325 | if (!"https".equals(u.getScheme())) { 326 | return FormValidation.errorWithMarkup("Issuer URIs should use https scheme"); 327 | } 328 | if (u.getQuery() != null) { 329 | return FormValidation.error("Issuer URIs must not have a query component"); 330 | } 331 | if (u.getFragment() != null) { 332 | return FormValidation.error("Issuer URIs must not have a fragment component"); 333 | } 334 | if (u.getPath() != null && u.getPath().endsWith("/")) { 335 | return FormValidation.errorWithMarkup("Issuer URIs should not end with a slash (/) in this context"); 336 | } 337 | } catch (URISyntaxException x) { 338 | return FormValidation.error("Not a well-formed URI"); 339 | } 340 | if (i != null) { 341 | IdTokenCredentials c = i.credentials().stream().filter(creds -> creds.getId().equals(id) && issuer.equals(creds.getIssuer())).findFirst().orElse(null); 342 | if (c != null) { 343 | String base = req.getRequestURI().replaceFirst("/checkIssuer$", ""); 344 | return FormValidation.okWithMarkup( 345 | "Serve " + Util.xmlEscape(issuer) + Keys.WELL_KNOWN_OPENID_CONFIGURATION + 346 | " with this content and " + 348 | Util.xmlEscape(issuer) + Keys.JWKS + " with this content (both as application/json)." + 351 | "
Note that the JWKS document will need to be updated if you resave these credentials."); 352 | } else { 353 | return FormValidation.ok("Save these credentials, then return to this screen for instructions"); 354 | } 355 | } else { 356 | return FormValidation.warning("Unable to determine where these credentials are being saved"); 357 | } 358 | } 359 | } 360 | 361 | public JSONObject doWellKnownOpenidConfiguration(@QueryParameter String issuer) { 362 | return Keys.openidConfiguration(issuer); 363 | } 364 | 365 | public JSONObject doJwks(StaplerRequest2 req, @QueryParameter String id, @QueryParameter String issuer) { 366 | Issuer i = issuerFromRequest(req); 367 | if (i == null) { 368 | throw HttpResponses.notFound(); 369 | } 370 | IdTokenCredentials c = i.credentials().stream().filter(creds -> creds.getId().equals(id) && issuer.equals(creds.getIssuer())).findFirst().orElse(null); 371 | if (c == null) { 372 | throw HttpResponses.notFound(); 373 | } 374 | return new JSONObject().accumulate("keys", new JSONArray().element(Keys.key(c))); 375 | } 376 | 377 | } 378 | 379 | } 380 | --------------------------------------------------------------------------------