From 30a36b83a2a9e5384d1d32fb3003a015b44a3b49 Mon Sep 17 00:00:00 2001 From: Pierre Beitz Date: Thu, 25 Sep 2025 14:59:00 +0200 Subject: [PATCH 1/4] [feat] Add support for the device authentication flow for github apps --- ...iceFlowGithubAppAuthorizationProvider.java | 345 ++++++++++++++++++ ...DeviceFlowGithubAppCredentialListener.java | 22 ++ .../DeviceFlowGithubAppCredentials.java | 148 ++++++++ .../DeviceFlowGithubAppInputManager.java | 22 ++ ...LoggerDeviceFlowGithubAppInputManager.java | 15 + .../github-api/reflect-config.json | 46 ++- .../github-api/serialization-config.json | 9 + ...lowGithubAppAuthorizationProviderTest.java | 50 +++ .../no-reflect-and-serialization-list | 7 +- 9 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProvider.java create mode 100644 src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentialListener.java create mode 100644 src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java create mode 100644 src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppInputManager.java create mode 100644 src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java create mode 100644 src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java diff --git a/src/main/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProvider.java b/src/main/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProvider.java new file mode 100644 index 0000000000..1edf3e1a81 --- /dev/null +++ b/src/main/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProvider.java @@ -0,0 +1,345 @@ +package org.kohsuke.github; + +import org.kohsuke.github.authorization.AuthorizationProvider; +import org.kohsuke.github.authorization.DeviceFlowGithubAppCredentialListener; +import org.kohsuke.github.authorization.DeviceFlowGithubAppCredentials; +import org.kohsuke.github.authorization.DeviceFlowGithubAppInputManager; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.logging.Logger; + +/** + * Provides authorization for GitHub applications using the device flow. See ... + * This class handles the device flow process, including requesting device codes, polling for access tokens, refreshing + * tokens, and managing credential states. + */ +public class DeviceFlowGithubAppAuthorizationProvider extends GitHubInteractiveObject implements AuthorizationProvider { + + /** + * Represents the response from GitHub's device flow access token endpoint. Contains access token, refresh token, + * expiration information, scope, and token type. We transform it to a {@link DeviceFlowGithubAppCredentials} object + * to expose it to the outside. + */ + private static class DeviceFlowAccessTokenResponse { + static DeviceFlowGithubAppCredentials toCredentials(DeviceFlowAccessTokenResponse response) { + var credentials = new DeviceFlowGithubAppCredentials(); + credentials.setAccessToken(response.getAccessToken()); + credentials.setExpiresIn( + response.getExpiresIn() > 0 ? Instant.now().plusSeconds(response.getExpiresIn()) : Instant.MIN); + credentials.setRefreshToken(response.getRefreshToken()); + credentials.setRefreshTokenExpiresIn(response.getRefreshTokenExpiresIn() > 0 + ? Instant.now().plusSeconds(response.getRefreshTokenExpiresIn()) + : Instant.MIN); + credentials.setScope(response.getScope()); + credentials.setTokenType(response.getTokenType()); + return credentials; + } + private String accessToken; + private int expiresIn; + private String refreshToken; + private int refreshTokenExpiresIn; + // should be empty + private String scope; + + // should be Bearer + private String tokenType; + + public String getAccessToken() { + return accessToken; + } + + public int getExpiresIn() { + return expiresIn; + } + + public String getRefreshToken() { + return refreshToken; + } + + public int getRefreshTokenExpiresIn() { + return refreshTokenExpiresIn; + } + + public String getScope() { + return scope; + } + + public String getTokenType() { + return tokenType; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public void setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void setRefreshTokenExpiresIn(int refreshTokenExpiresIn) { + this.refreshTokenExpiresIn = refreshTokenExpiresIn; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + } + + /** + * Represents the response from GitHub's device flow code endpoint. Contains device code, user code, verification + * URI, expiration, and polling interval. + */ + private static class DeviceFlowCodeResponse { + private String deviceCode; + private int expiresIn; + private int interval; + private String userCode; + private String verificationUri; + + public String getDeviceCode() { + return deviceCode; + } + + public int getExpiresIn() { + return expiresIn; + } + + public int getInterval() { + return interval; + } + + public String getUserCode() { + return userCode; + } + + public String getVerificationUri() { + return verificationUri; + } + + public void setDeviceCode(String deviceCode) { + this.deviceCode = deviceCode; + } + + public void setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + } + + public void setInterval(int interval) { + this.interval = interval; + } + + public void setUserCode(String userCode) { + this.userCode = userCode; + } + + public void setVerificationUri(String verificationUri) { + this.verificationUri = verificationUri; + } + } + + /** + * Represents the possible states of the credentials. + */ + private enum State { + EXPIRED_ACCESS_TOKEN, EXPIRED_REFRESH_TOKEN, NO_ACCESS_TOKEN, NO_REFRESH_TOKEN, VALID_ACCESS_TOKEN + } + private static final Logger LOGGER = Logger.getLogger(DeviceFlowGithubAppAuthorizationProvider.class.getName()); + private static final int TOKEN_EXPIRATION_MARGIN_MINUTES = 5; + private static final int USER_VERIFICATION_CODE_ATTEMPTS = 20; + + private final DeviceFlowGithubAppCredentialListener accessTokenListener; + + private DeviceFlowGithubAppCredentials appCredentials; + + private final String clientId; + + private final DeviceFlowGithubAppInputManager inputManager; + + /** + * Constructs a new DeviceFlowGithubAppAuthorizationProvider. + * + * @param clientId + * The client ID of the GitHub app. + * @param appCredentials + * The initial credentials for the app. + * @param accessTokenListener + * The listener to notify when new credentials are received (either the first time or through a refresh). + * @param inputManager + * The input manager for handling user input during the device flow (see + * {@link DeviceFlowGithubAppInputManager} for details). + * @throws IOException + * If an I/O error occurs. + */ + public DeviceFlowGithubAppAuthorizationProvider(String clientId, + DeviceFlowGithubAppCredentials appCredentials, + DeviceFlowGithubAppCredentialListener accessTokenListener, + DeviceFlowGithubAppInputManager inputManager) throws IOException { + this(clientId, appCredentials, accessTokenListener, inputManager, GitHub.connectAnonymously()); + } + + /** + * Constructs a new DeviceFlowGithubAppAuthorizationProvider with a specified GitHub instance. This is useful for + * testing, outside of tests you should not have to provide a GitHub instance. + * + * @param clientId + * The client ID of the GitHub app. + * @param appCredentials + * The initial credentials for the app. + * @param accessTokenListener + * The listener to notify when new credentials are received (either the first time or through a refresh). + * @param inputManager + * The input manager for handling user input during the device flow (see + * {@link DeviceFlowGithubAppInputManager} for details). + * @param github + * The GitHub instance to use for API requests. + */ + DeviceFlowGithubAppAuthorizationProvider(String clientId, + DeviceFlowGithubAppCredentials appCredentials, + DeviceFlowGithubAppCredentialListener accessTokenListener, + DeviceFlowGithubAppInputManager inputManager, + GitHub github) { + super(github); + this.clientId = clientId; + this.appCredentials = appCredentials; + this.accessTokenListener = accessTokenListener; + this.inputManager = inputManager; + } + + /** + * @inheritDoc + */ + @Override + public String getEncodedAuthorization() throws IOException { + // 5 possible cases + // * very 1s call, we do not have anything, no access token, no refresh token + // * we have a valid access token + // * we have an expired access token + // * we do not have a refresh token + // * we have an expired refresh token + // Note that technically if the user did not properly persist the information we could have other states + // like for instance having an object with no access token but a refresh token, but let's KISS it for now + switch (getCredentialState()) { + case VALID_ACCESS_TOKEN : + return appCredentials.toEncodedCredentials(); + case EXPIRED_ACCESS_TOKEN : + return refreshToken(); + case NO_ACCESS_TOKEN : + case NO_REFRESH_TOKEN : + case EXPIRED_REFRESH_TOKEN : + default : + return performDeviceFlow(); + } + } + + private State getCredentialState() { + if (appCredentials == null || appCredentials.getAccessToken() == null) { + return State.NO_ACCESS_TOKEN; + } + if (appCredentials.getExpiresIn() + .minus(TOKEN_EXPIRATION_MARGIN_MINUTES, ChronoUnit.MINUTES) + .isAfter(Instant.now())) { + return State.VALID_ACCESS_TOKEN; + } + if (appCredentials.getRefreshToken() == null) { + return State.NO_REFRESH_TOKEN; + } + if (appCredentials.getRefreshTokenExpiresIn() + .minus(TOKEN_EXPIRATION_MARGIN_MINUTES, ChronoUnit.MINUTES) + .isAfter(Instant.now())) { + return State.EXPIRED_ACCESS_TOKEN; + } + return State.EXPIRED_REFRESH_TOKEN; + } + + private String performDeviceFlow() throws IOException { + var deviceCodeResponse = requestDeviceCode(); + inputManager.handleVerificationCodeFlow(deviceCodeResponse.getVerificationUri(), + deviceCodeResponse.getUserCode()); + var accessTokenResponse = pollForAccessToken(deviceCodeResponse); + return refreshCredentialsAndNotifyListener(accessTokenResponse); + } + + private DeviceFlowAccessTokenResponse pollForAccessToken(DeviceFlowCodeResponse deviceFlowCodeResponse) + throws IOException { + var attempts = 0; + while (attempts < USER_VERIFICATION_CODE_ATTEMPTS) { + var request = GitHubRequest.newBuilder() + .method("POST") + .setRawUrlPath("https://github.com/login/oauth/access_token") + .setHeader("Accept", "application/json") + .with("client_id", clientId) + .with("device_code", deviceFlowCodeResponse.getDeviceCode()) + .with("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .inBody() + .build(); + var accessTokenResponse = root().getClient() + .sendRequest(request, r -> GitHubResponse.parseBody(r, DeviceFlowAccessTokenResponse.class)) + .body(); + if (accessTokenResponse != null && accessTokenResponse.getAccessToken() != null) { + LOGGER.finest("Access token obtained: " + accessTokenResponse.getAccessToken()); + return accessTokenResponse; + } + var intervalSeconds = deviceFlowCodeResponse.getInterval(); + if (intervalSeconds <= 0) { + // this is the default in the GitHub doc + intervalSeconds = 5; + } + attempts++; + LOGGER.finest(String.format("No access token, sleeping for %d seconds", intervalSeconds)); + try { + Thread.sleep(intervalSeconds * 1000L); + } catch (InterruptedException e) { + throw (IOException) new InterruptedIOException().initCause(e); + } + } + throw new IOException("User failed to provide the verification code in the allocated time"); + } + + private String refreshCredentialsAndNotifyListener(DeviceFlowAccessTokenResponse accessTokenResponse) + throws IOException { + appCredentials = DeviceFlowAccessTokenResponse.toCredentials(accessTokenResponse); + accessTokenListener.onAccessTokenReceived(appCredentials); + return appCredentials.toEncodedCredentials(); + } + + private String refreshToken() throws IOException { + var request = GitHubRequest.newBuilder() + .method("POST") + .setRawUrlPath("https://github.com/login/oauth/access_token") + .setHeader("Accept", "application/json") + .with("client_id", clientId) + .with("grant_type", "refresh_token") + .with("refresh_token", appCredentials.getRefreshToken()) + .inBody() + .build(); + var accessTokenResponse = root().getClient() + .sendRequest(request, r -> GitHubResponse.parseBody(r, DeviceFlowAccessTokenResponse.class)) + .body(); + return refreshCredentialsAndNotifyListener(accessTokenResponse); + } + + private DeviceFlowCodeResponse requestDeviceCode() throws IOException { + var request = GitHubRequest.newBuilder() + .method("POST") + .setRawUrlPath("https://github.com/login/device/code") + .setHeader("Accept", "application/json") + .with("client_id", clientId) + .inBody() + .build(); + return root().getClient() + .sendRequest(request, r -> GitHubResponse.parseBody(r, DeviceFlowCodeResponse.class)) + .body(); + } +} diff --git a/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentialListener.java b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentialListener.java new file mode 100644 index 0000000000..5adb8791a4 --- /dev/null +++ b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentialListener.java @@ -0,0 +1,22 @@ +package org.kohsuke.github.authorization; + +import java.io.IOException; + +/** + * Functional interface for handling events when a new access token is received. Implementations of this interface + * define the behavior to execute upon receiving new credentials during the GitHub device flow authorization process. + * Usually the caller is expected to securely persist the credentials for future use. + */ +@FunctionalInterface +public interface DeviceFlowGithubAppCredentialListener { + + /** + * Called when a new access token is received. It is the responsibility of the caller to securely store the object. + * + * @param appCredentials + * The new credentials containing the access token and related information. + * @throws IOException + * If an I/O error occurs while processing the credentials. + */ + void onAccessTokenReceived(DeviceFlowGithubAppCredentials appCredentials) throws IOException; +} diff --git a/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java new file mode 100644 index 0000000000..2a29a16b3e --- /dev/null +++ b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java @@ -0,0 +1,148 @@ +package org.kohsuke.github.authorization; + +import java.time.Instant; + +/** + * Represents the credentials obtained during the GitHub device flow authorization process. Contains access token, + * refresh token, expiration times, scope, and token type. The content of this object should be treated as sensitive + * information and be properly and securely stored. + */ +public class DeviceFlowGithubAppCredentials { + /** + * A constant representing empty credentials. Probably what you want to use on the first call before you have any + * credentials persisted. + */ + public static final DeviceFlowGithubAppCredentials EMPTY_CREDENTIALS = new DeviceFlowGithubAppCredentials(); + + private String accessToken; + private Instant expiresIn; + private String refreshToken; + private Instant refreshTokenExpiresIn; + // should be empty + private String scope; + // should be Bearer + private String tokenType; + + /** + * Gets the access token. + * + * @return The access token. + */ + public String getAccessToken() { + return accessToken; + } + + /** + * Gets the expiration time of the access token. + * + * @return The expiration time as an {@link Instant}. + */ + public Instant getExpiresIn() { + return expiresIn; + } + + /** + * Gets the refresh token. + * + * @return The refresh token. + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * Gets the expiration time of the refresh token. + * + * @return The expiration time as an {@link Instant}. + */ + public Instant getRefreshTokenExpiresIn() { + return refreshTokenExpiresIn; + } + + /** + * Gets the scope of the credentials. + * + * @return The scope, which should be empty. + */ + public String getScope() { + return scope; + } + + /** + * Gets the token type. + * + * @return The token type, which should be "Bearer". + */ + public String getTokenType() { + return tokenType; + } + + /** + * Sets the access token. + * + * @param accessToken + * The access token to set. + */ + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + /** + * Sets the expiration time of the access token. + * + * @param expiresIn + * The expiration time as an {@link Instant}. + */ + public void setExpiresIn(Instant expiresIn) { + this.expiresIn = expiresIn; + } + + /** + * Sets the refresh token. + * + * @param refreshToken + * The refresh token to set. + */ + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + /** + * Sets the expiration time of the refresh token. + * + * @param refreshTokenExpiresIn + * The expiration time as an {@link Instant}. + */ + public void setRefreshTokenExpiresIn(Instant refreshTokenExpiresIn) { + this.refreshTokenExpiresIn = refreshTokenExpiresIn; + } + + /** + * Sets the scope of the credentials. + * + * @param scope + * The scope to set, which should be empty. + */ + public void setScope(String scope) { + this.scope = scope; + } + + /** + * Sets the token type. + * + * @param tokenType + * The token type to set, which should be "Bearer". + */ + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + /** + * Converts the credentials to an encoded authorization string. + * + * @return The encoded authorization string in the format "Bearer {accessToken}". + */ + public String toEncodedCredentials() { + return String.format("Bearer %s", getAccessToken()); + } +} diff --git a/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppInputManager.java b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppInputManager.java new file mode 100644 index 0000000000..b3e1afe69e --- /dev/null +++ b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppInputManager.java @@ -0,0 +1,22 @@ +package org.kohsuke.github.authorization; + +/** + * Functional interface for managing user input during the GitHub device flow authorization process. Implementations of + * this interface define how to handle the verification URI and user code provided by GitHub for user authentication. + * Usually you would be expected to redirect the user to the github code verification page (open the page, dump a + * message to the console, etc) and show the verification code to the user. See + * {@link LoggerDeviceFlowGithubAppInputManager} for an example. + */ +@FunctionalInterface +public interface DeviceFlowGithubAppInputManager { + + /** + * Handles the user input flow for the device verification process. + * + * @param verificationUri + * The URI where the user needs to navigate to verify the device. + * @param userCode + * The user code that the user needs to enter at the verification URI. + */ + void handleVerificationCodeFlow(String verificationUri, String userCode); +} diff --git a/src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java b/src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java new file mode 100644 index 0000000000..0e206f90ed --- /dev/null +++ b/src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java @@ -0,0 +1,15 @@ +package org.kohsuke.github.authorization; + +import java.util.logging.Logger; + +/** + * A simple implementation of {@link DeviceFlowGithubAppInputManager} that logs the verification URI and user code. + */ +public class LoggerDeviceFlowGithubAppInputManager implements DeviceFlowGithubAppInputManager { + private static final Logger LOGGER = Logger.getLogger(LoggerDeviceFlowGithubAppInputManager.class.getName()); + + @Override + public void handleVerificationCodeFlow(String verificationUri, String userCode) { + LOGGER.info("Please go to " + verificationUri + " and enter the code: " + userCode); + } +} diff --git a/src/main/resources/META-INF/native-image/org.kohsuke/github-api/reflect-config.json b/src/main/resources/META-INF/native-image/org.kohsuke/github-api/reflect-config.json index 30be262b74..570914dee7 100644 --- a/src/main/resources/META-INF/native-image/org.kohsuke/github-api/reflect-config.json +++ b/src/main/resources/META-INF/native-image/org.kohsuke/github-api/reflect-config.json @@ -6823,6 +6823,50 @@ "allDeclaredMethods": true, "allPublicClasses": true, "allDeclaredClasses": true + }, + { + "name": "org.kohsuke.github.authorization.DeviceFlowGithubAppCredentials", + "allPublicFields": true, + "allDeclaredFields": true, + "queryAllPublicConstructors": true, + "queryAllDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredConstructors": true, + "queryAllPublicMethods": true, + "queryAllDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredMethods": true, + "allPublicClasses": true, + "allDeclaredClasses": true + }, + { + "name": "org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$DeviceFlowAccessTokenResponse", + "allPublicFields": true, + "allDeclaredFields": true, + "queryAllPublicConstructors": true, + "queryAllDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredConstructors": true, + "queryAllPublicMethods": true, + "queryAllDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredMethods": true, + "allPublicClasses": true, + "allDeclaredClasses": true + }, + { + "name": "org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$DeviceFlowCodeResponse", + "allPublicFields": true, + "allDeclaredFields": true, + "queryAllPublicConstructors": true, + "queryAllDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredConstructors": true, + "queryAllPublicMethods": true, + "queryAllDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredMethods": true, + "allPublicClasses": true, + "allDeclaredClasses": true } - ] diff --git a/src/main/resources/META-INF/native-image/org.kohsuke/github-api/serialization-config.json b/src/main/resources/META-INF/native-image/org.kohsuke/github-api/serialization-config.json index 412aa47e18..13ea2a83e6 100644 --- a/src/main/resources/META-INF/native-image/org.kohsuke/github-api/serialization-config.json +++ b/src/main/resources/META-INF/native-image/org.kohsuke/github-api/serialization-config.json @@ -1366,5 +1366,14 @@ }, { "name": "org.kohsuke.github.GitHubBridgeAdapterObject" + }, + { + "name": "org.kohsuke.github.authorization.DeviceFlowGithubAppCredentials" + }, + { + "name": "org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$DeviceFlowAccessTokenResponse" + }, + { + "name": "org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$DeviceFlowCodeResponse" } ] diff --git a/src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java b/src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java new file mode 100644 index 0000000000..82d079ec5f --- /dev/null +++ b/src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java @@ -0,0 +1,50 @@ +package org.kohsuke.github; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.kohsuke.github.authorization.LoggerDeviceFlowGithubAppInputManager; + +import java.io.IOException; + +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.kohsuke.github.authorization.DeviceFlowGithubAppCredentials.EMPTY_CREDENTIALS; + +public class DeviceFlowGithubAppAuthorizationProviderTest extends AbstractGitHubWireMockTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Ignore + @Test + public void performDeviceFlow() throws Exception { + var clientId = "TODO"; + var objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + var appCredentialsFile = tempFolder.newFile().toPath(); + var appCredentials = EMPTY_CREDENTIALS; + + DeviceFlowGithubAppAuthorizationProvider provider = new DeviceFlowGithubAppAuthorizationProvider(clientId, + appCredentials, + ac -> { + try { + objectMapper.writeValue(appCredentialsFile.toFile(), ac); + } catch (IOException e) { + throw new RuntimeException(e); + } + }, + new LoggerDeviceFlowGithubAppInputManager(), + getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()).build()); + gitHub = getGitHubBuilder().withAuthorizationProvider(provider) + .withEndpoint(mockGitHub.apiServer().baseUrl()) + .build(); + + // verify a protected resource can be accessed + var myself = gitHub.getMyself(); + assertThat(myself, notNullValue()); + } +} diff --git a/src/test/resources/no-reflect-and-serialization-list b/src/test/resources/no-reflect-and-serialization-list index 4ad893272c..81bfb034de 100644 --- a/src/test/resources/no-reflect-and-serialization-list +++ b/src/test/resources/no-reflect-and-serialization-list @@ -83,4 +83,9 @@ org.kohsuke.github.internal.EnumUtils org.kohsuke.github.internal.Previews org.kohsuke.github.EnterpriseManagedSupport org.kohsuke.github.GHAutolinkBuilder -org.kohsuke.github.GHRepositoryForkBuilder \ No newline at end of file +org.kohsuke.github.GHRepositoryForkBuilder +org.kohsuke.github.authorization.DeviceFlowGithubAppCredentialListener +org.kohsuke.github.authorization.DeviceFlowGithubAppInputManager +org.kohsuke.github.authorization.LoggerDeviceFlowGithubAppInputManager +org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider +org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$State \ No newline at end of file From b9877c7389c26f6f094782a90cc82ad21d3bcc16 Mon Sep 17 00:00:00 2001 From: Pierre Beitz Date: Thu, 25 Sep 2025 15:16:32 +0200 Subject: [PATCH 2/4] Add doc --- src/site/apt/githubappdeviceflowauth.app | 38 ++++++++++++++++++++++++ src/site/apt/githubappflow.apt | 4 +++ 2 files changed, 42 insertions(+) create mode 100644 src/site/apt/githubappdeviceflowauth.app diff --git a/src/site/apt/githubappdeviceflowauth.app b/src/site/apt/githubappdeviceflowauth.app new file mode 100644 index 0000000000..b3bdd2d7e3 --- /dev/null +++ b/src/site/apt/githubappdeviceflowauth.app @@ -0,0 +1,38 @@ +Authenticating as a user + + In order to authenticate to GitHub as an user using the device flow of your GitHub App, you need to use the <<>> + authentication provider that will take care of retrieving a user access token and refresh it when needed for you. + You need to handle two things by yourself: + 1. You need to provide a <<>> that will be called when a new user access token is retrieved (either on initial creation or on refresh). + It is up to you to store the credential object securely the library does not take care of that. + 2. You need to provide a <<>> that will be called when a user interaction is needed to complete the device flow. + The library provides a basic implementation <<>> that will log the instructions to the console but you could imagine a more complex + implementation that would for example open the user browser automatically (or call some automation that will input the information automatically for instance). + + Here is a complete example to get started: + ++-----+ + var clientId = ""; + var objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + // for demo purpose only, this is not proper secret management!!! + var appCredentialsFile = Path.of("/tmp/github-app-credentials.json"); + DeviceFlowGithubAppCredentials appCredentials; + if (Files.exists(appCredentialsFile)) { + appCredentials = objectMapper.readValue(appCredentialsFile.toFile(), DeviceFlowGithubAppCredentials.class); + } else { + appCredentials = EMPTY_CREDENTIALS; + } + + var gh = new GitHubBuilder().withAuthorizationProvider( + new DeviceFlowGithubAppAuthorizationProvider(clientId, appCredentials, ac -> { + // in this basic example, we serialize the credentials as json to a file + // this is not proper secret management and you should probably use something more secure + try { + objectMapper.writeValue(appCredentialsFile.toFile(), ac); + } catch (IOException e) { + throw new RuntimeException(e); + } + }, new LoggerDeviceFlowGithubAppInputManager())).build(); ++-----+ \ No newline at end of file diff --git a/src/site/apt/githubappflow.apt b/src/site/apt/githubappflow.apt index fe786eaa62..05138e589e 100644 --- a/src/site/apt/githubappflow.apt +++ b/src/site/apt/githubappflow.apt @@ -30,6 +30,8 @@ Prerequisites * A User or an organisation that has already installed your GitHub App as described {{{https://developer.github.com/apps/installing-github-apps/}here}} + * [If you intend to use the device flow]: the *Enable Device Flow* option must be enabled in your GitHub App settings. + [] What next? @@ -38,4 +40,6 @@ What next? * Authenticating as an installation via the {{{/githubappappinsttokenauth.html}App Installation Token}} + * Authenticating as a user via the {{{/githubappdeviceflowauth.html}Device Flow}} + [] From 8e79c302557b5ef931d1e1d2042072a832d23fca Mon Sep 17 00:00:00 2001 From: Pierre Beitz Date: Fri, 26 Sep 2025 10:24:27 +0200 Subject: [PATCH 3/4] Add missing native configuration --- src/test/resources/no-reflect-and-serialization-list | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/resources/no-reflect-and-serialization-list b/src/test/resources/no-reflect-and-serialization-list index 81bfb034de..18dce5fb24 100644 --- a/src/test/resources/no-reflect-and-serialization-list +++ b/src/test/resources/no-reflect-and-serialization-list @@ -88,4 +88,5 @@ org.kohsuke.github.authorization.DeviceFlowGithubAppCredentialListener org.kohsuke.github.authorization.DeviceFlowGithubAppInputManager org.kohsuke.github.authorization.LoggerDeviceFlowGithubAppInputManager org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider -org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$State \ No newline at end of file +org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$State +org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$1 \ No newline at end of file From 41ff37b004eaf85b744394fb63467a0207506ece Mon Sep 17 00:00:00 2001 From: Pierre Beitz Date: Fri, 26 Sep 2025 11:05:38 +0200 Subject: [PATCH 4/4] fix javadoc --- ...iceFlowGithubAppAuthorizationProvider.java | 2 +- .../DeviceFlowGithubAppCredentials.java | 7 +++++++ ...LoggerDeviceFlowGithubAppInputManager.java | 7 +++++++ ...lowGithubAppAuthorizationProviderTest.java | 21 +++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProvider.java b/src/main/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProvider.java index 1edf3e1a81..90af6173ed 100644 --- a/src/main/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProvider.java +++ b/src/main/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProvider.java @@ -218,7 +218,7 @@ public DeviceFlowGithubAppAuthorizationProvider(String clientId, } /** - * @inheritDoc + * {@inheritDoc} */ @Override public String getEncodedAuthorization() throws IOException { diff --git a/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java index 2a29a16b3e..40ec334d64 100644 --- a/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java +++ b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java @@ -23,6 +23,13 @@ public class DeviceFlowGithubAppCredentials { // should be Bearer private String tokenType; + /** + * Default constructor for creating an empty credentials object. + */ + public DeviceFlowGithubAppCredentials() { + // empty + } + /** * Gets the access token. * diff --git a/src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java b/src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java index 0e206f90ed..f77a169277 100644 --- a/src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java +++ b/src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java @@ -8,6 +8,13 @@ public class LoggerDeviceFlowGithubAppInputManager implements DeviceFlowGithubAppInputManager { private static final Logger LOGGER = Logger.getLogger(LoggerDeviceFlowGithubAppInputManager.class.getName()); + /** + * Default constructor for LoggerDeviceFlowGithubAppInputManager. + */ + public LoggerDeviceFlowGithubAppInputManager() { + // empty + } + @Override public void handleVerificationCodeFlow(String verificationUri, String userCode) { LOGGER.info("Please go to " + verificationUri + " and enter the code: " + userCode); diff --git a/src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java b/src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java index 82d079ec5f..082f4960f5 100644 --- a/src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java +++ b/src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java @@ -13,11 +13,32 @@ import static org.hamcrest.CoreMatchers.notNullValue; import static org.kohsuke.github.authorization.DeviceFlowGithubAppCredentials.EMPTY_CREDENTIALS; +/** + * Unit test for the {@link DeviceFlowGithubAppAuthorizationProvider} class. This test verifies the device flow + * authorization process and ensures that protected resources can be accessed after successful authentication. + */ public class DeviceFlowGithubAppAuthorizationProviderTest extends AbstractGitHubWireMockTest { + /** + * Temporary folder rule to create temporary files and directories during the test. + */ @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + /** + * Instantiates a new test. + */ + public DeviceFlowGithubAppAuthorizationProviderTest() { + // empty + } + + /** + * Test for the device flow authorization process. This test is ignored by default and requires manual setup of the + * client ID. + * + * @throws Exception + * If an error occurs during the test execution. + */ @Ignore @Test public void performDeviceFlow() throws Exception {