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..90af6173ed
--- /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..40ec334d64
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/authorization/DeviceFlowGithubAppCredentials.java
@@ -0,0 +1,155 @@
+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;
+
+ /**
+ * Default constructor for creating an empty credentials object.
+ */
+ public DeviceFlowGithubAppCredentials() {
+ // empty
+ }
+
+ /**
+ * 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..f77a169277
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/authorization/LoggerDeviceFlowGithubAppInputManager.java
@@ -0,0 +1,22 @@
+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());
+
+ /**
+ * 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/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/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}}
+
[]
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..082f4960f5
--- /dev/null
+++ b/src/test/java/org/kohsuke/github/DeviceFlowGithubAppAuthorizationProviderTest.java
@@ -0,0 +1,71 @@
+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;
+
+/**
+ * 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 {
+ 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..18dce5fb24 100644
--- a/src/test/resources/no-reflect-and-serialization-list
+++ b/src/test/resources/no-reflect-and-serialization-list
@@ -83,4 +83,10 @@ 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
+org.kohsuke.github.DeviceFlowGithubAppAuthorizationProvider$1
\ No newline at end of file