Keycloak for Smart Devices: A Practical Guide to Device Code Flow 2/2

In the previous post, we learned why Device Code Flow matters for smart devices and how to set up Keycloak to support it. In this post, we’ll implement Device Code Flow using Spring Cloud Gateway and Flutter.

· Prerequisites
∘ Step 2: Spring Cloud Gateway Configuration
∘ Step 3: Flutter Android App Implementation
· Testing the Complete Flow
· Troubleshooting Common Issues
∘ “Invalid Client” Error
∘ Polling Returns “authorization_pending” Indefinitely
∘ Tokens Expire Too Quickly
∘ User Can’t Find Verification Page
· Conclusion
· References


Prerequisites

This is the list of all the prerequisites:

  • All steps in Part 1 must be completed
  • Android Studio, Visual Studio Code, or another IDE

Step 2: Spring Cloud Gateway Configuration

Now, let’s configure our gateway to handle the device flow and validate tokens.

Create a simple Spring Boot project from start.spring.io, with the following dependencies: Spring Cloud Gateway, Spring Security, OAuth2 Client, and Lombok.

Gateway Configuration

server:
port: 8081


keycloak-client:
server-url: http://localhost:8080 # Base URL of Keycloak
realm: bootlabs # Realm name in Keycloak
client-id: smart-device-client # Client registered in that realm
client-secret: F1emNBw8wnXnSpYnEltdqJUspugUdl2t # Client secret (for confidential clients)
device-oauth-endpoint: ${keycloak-client.server-url}/realms/${keycloak-client.realm}/protocol/openid-connect/auth/device

spring:
application:
name: device-flow-gateway
cloud:
gateway:
server:
webflux:
routes:
- id: resource-server
uri: ${RESOURCE_SERVER_URI:http://localhost:8082}
predicates:
- Path=/resource/**
filters:
- StripPrefix=1

security:
oauth2:
client:
registration:
keycloak:
client-id: ${keycloak-client.client-id}
client-secret: ${keycloak-client.client-secret}
redirect-uri: ${baseUrl}/login/oauth2/code/keycloak
authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code
scope: openid,profile,email
provider:
keycloak:
issuer-uri: ${keycloak-client.server-url}/realms/${keycloak-client.realm}
user-name-attribute: preferred_username

Security Configuration

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchanges -> exchanges
// Public endpoints
.pathMatchers("/device/**").permitAll()
.pathMatchers("/actuator/health").permitAll()
// Protected endpoints
.anyExchange().authenticated()
)
.oauth2Login(withDefaults())
.oauth2Client(withDefaults());
return http.build();
}


@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*")); // Or specify your Flutter app's origin
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}

@Bean
public WebClient webClient() {
return WebClient.builder().build();
}
}

Custom Gateway Controller for Device Flow

Create a controller to handle device flow initiation with additional gateway logic:

@Slf4j
@RestController
@RequestMapping("/device")
public class DeviceCodeController {

private final WebClient webClient;
private final ReactiveClientRegistrationRepository clientRegistrationRepository;

private final String deviceOAuthEndpoint;

public DeviceCodeController(WebClient webClient, ReactiveClientRegistrationRepository clientRegistrationRepository, @Value("${keycloak-client.device-oauth-endpoint}") String deviceOAuthEndpoint) {
this.webClient = webClient;
this.clientRegistrationRepository = clientRegistrationRepository;
this.deviceOAuthEndpoint = deviceOAuthEndpoint;

}

@GetMapping("/code")
public Mono<DeviceCodeResponse> getDeviceCode() {
return clientRegistrationRepository.findByRegistrationId("keycloak")
.flatMap(registration -> {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", registration.getClientId());
params.add("scope", String.join(" ", registration.getScopes()));

return webClient.post()
.uri(deviceOAuthEndpoint)
.headers(headers -> headers.setBasicAuth(registration.getClientId(),
registration.getClientSecret()))
.body(BodyInserters.fromFormData(params))
.retrieve()
.bodyToMono(DeviceCodeResponse.class)
.doOnNext(response -> {
// Log the device code request for audit purposes
log.info("Device code requested for client: {}, user code: {}",
registration.getClientId(), response.getUserCode());
});
});
}


@GetMapping("/token")
public Mono<TokenResponse> getToken(@RequestParam String deviceCode) {
return clientRegistrationRepository.findByRegistrationId("keycloak")
.flatMap(registration -> {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", AuthorizationGrantType.DEVICE_CODE.getValue());
params.add("device_code", deviceCode);
params.add("client_id", registration.getClientId());

return webClient.post()
.uri(registration.getProviderDetails().getTokenUri())
.headers(headers -> headers.setBasicAuth(registration.getClientId(),
registration.getClientSecret()))
.body(BodyInserters.fromFormData(params))
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, response -> {
if (response.statusCode() == HttpStatus.NOT_FOUND) {
return Mono.error(new RuntimeException("Token endpoint not found (404)"));
}
return Mono.error(new RuntimeException("Client error: " + response));
})
.onStatus(HttpStatusCode::is5xxServerError, response -> {
return Mono.error(new RuntimeException("Client error: " + response));
})
.bodyToMono(TokenResponse.class);
});
}


@Data
public static class DeviceCodeResponse {

@JsonProperty("device_code")
private String deviceCode;

@JsonProperty("user_code")
private String userCode;

@JsonProperty("verification_uri")
private String verificationUri;

@JsonProperty("expires_in")
private Integer expiresIn;

@JsonProperty("interval")
private Integer interval;

@JsonProperty("verification_uri_complete")
private String verificationUriComplete;
}


@Data
public static class TokenResponse {
@JsonProperty("access_token")
private String accessToken;

@JsonProperty("refresh_token")
private String refreshToken;

@JsonProperty("expires_in")
private int expiresIn;

@JsonProperty("token_type")
private String tokenType;
}
}

This controller exposes two reactive REST endpoints that implement the OAuth 2.0 Device Authorization Flow using Spring WebFlux and Keycloak.

When a client calls /device/code, the controller retrieves the Keycloak client registration and sends a POST request to the device authorization endpoint with the client ID and scopes. Keycloak returns a DeviceCodeResponse containing a device_codeuser_code, and verification URLs that the user must visit to authorize the device.

The second endpoint, /device/token, receives a device code and again uses the client registration to send a POST request to the token endpoint, requesting an access token with the urn:ietf:params:oauth:grant-type:device_code grant type.

Step 3: Flutter Android App Implementation

Now for the Flutter app that will use Device Code Flow. It only communicates with the Spring Cloud Gateway.

Dependencies

Add to your pubspec.yaml:

dependencies:
flutter:
sdk: flutter

qr_flutter: ^4.1.0
flutter_bloc: ^9.1.1
dio: ^5.9.0
equatable: ^2.0.7
get_it: ^8.2.0
flutter_secure_storage: ^8.0.0

We’ll consume our REST API with the Cubit/Bloc.

Device Flow Service

Our Flutter app will handle the device flow logic: getting the code and polling for the token.


class AuthCubit extends Cubit<AuthState> {

final ApiService apiService;

AuthCubit({required this.apiService}) : super(AuthInitial());

startTokenPolling(String deviceCode) async {
emit(AuthLoading());

while (true) {
try {
final resp = await apiService.get(endPoint: "/device/token", params: {"deviceCode": deviceCode});

final tokenResponse = TokenResponse.fromJson(resp.data);

if (tokenResponse.accessToken != null) {
emit(AuthLoaded(response: resp));
break;
}

} catch (error) {
emit(AuthError(errorResponse: error.toString()));
}

await Future.delayed(Duration(seconds: 5));
}
}

}



class DeviceCodeCubit extends Cubit<DeviceCodeState> {
final ApiService apiService;

DeviceCodeCubit({required this.apiService}) : super(DeviceCodeInitial());

requestDeviceCode() async {

try {
emit(DeviceCodeLoading());
final resp = await apiService.get(endPoint: '/device/code');

// Parse the response into DeviceCodeResponse
final deviceCodeResponse = DeviceCodeResponse.fromJson(resp.data);


emit(DeviceCodeLoaded(response: deviceCodeResponse));
} catch (error) {
emit(DeviceCodeError(errorResponse: error.toString()));
}
}
}


class DeviceCodeResponse {
final String deviceCode;
final String userCode;
final String verificationUri;
final String verificationUriComplete;
final int expiresIn;
final int interval;

DeviceCodeResponse({
required this.deviceCode,
required this.userCode,
required this.verificationUri,
required this.verificationUriComplete,
required this.expiresIn,
required this.interval,
});

factory DeviceCodeResponse.fromJson(Map<String, dynamic> json) {
return DeviceCodeResponse(
deviceCode: json['device_code'],
userCode: json['user_code'],
verificationUri: json['verification_uri'],
verificationUriComplete: json['verification_uri_complete'] ?? json['verification_uri'],
expiresIn: json['expires_in'],
interval: json['interval'] ?? 5,
);
}
}


class TokenResponse {
String? accessToken;
String? refreshToken;
int? expiresIn;
String? tokenType;

TokenResponse({
this.accessToken,
this.refreshToken,
this.expiresIn,
this.tokenType,
});

factory TokenResponse.fromJson(Map<String, dynamic> json) {
return TokenResponse(
accessToken: json['access_token'],
refreshToken: json['refresh_token'],
expiresIn: json['expires_in'],
tokenType: json['token_type'],
);
}
}

Login Screen UI


class SetupScreen extends StatefulWidget {
const SetupScreen({super.key});

@override
State<SetupScreen> createState() => _SetupScreenState();
}

class _SetupScreenState extends State<SetupScreen> {
late final DeviceCodeCubit deviceCodeCubit;

@override
void initState() {
super.initState();
deviceCodeCubit = locator.get<DeviceCodeCubit>();
deviceCodeCubit.requestDeviceCode();
}

@override
void dispose() {
deviceCodeCubit.close();
super.dispose();
}

@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: deviceCodeCubit),
BlocProvider(create: (_) => locator.get<AuthCubit>()),
],
child: Scaffold(
body: MultiBlocListener(
listeners: [
/// When DeviceCode is loaded, start polling token
BlocListener<DeviceCodeCubit, DeviceCodeState>(
listener: (context, state) {
if (state is DeviceCodeLoaded) {
final deviceCodeResponse = state.response;
final deviceCode = deviceCodeResponse.deviceCode;
context.read<AuthCubit>().startTokenPolling(deviceCode);
}
},
),

/// When AuthCubit gets a token, navigate
BlocListener<AuthCubit, AuthState>(
listener: (context, state) {
if (state is AuthLoaded) {
// when token received → navigate
Navigator.pushReplacementNamed(context, RouteConstants.home);
}

if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Auth Error: ${state.errorResponse}")),
);
}
},
),
],
child: BlocBuilder<DeviceCodeCubit, DeviceCodeState>(
builder: (context, state) {
if (state is DeviceCodeLoading || state is DeviceCodeInitial) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.blueGrey),
SizedBox(height: 20),
Text("Loading ...", style: TextStyle(color: Colors
.white)),
],
),
);
}

if (state is DeviceCodeError) {
return Center(
child: ElevatedButton(
onPressed: () =>
context.read<DeviceCodeCubit>().requestDeviceCode(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF9500),
padding: const EdgeInsets.symmetric(
horizontal: 50,
vertical: 15,
),
textStyle: const TextStyle(fontSize: 18),
),
child: const Text(
'Reload',
style: TextStyle(color: Colors.white),
),
),
);
}

return _buildLoadedUI(state as DeviceCodeLoaded);
},
),
),
),
);
}

Widget _buildLoadedUI(DeviceCodeLoaded state) {
// Now state.response is already a DeviceCodeResponse object
final deviceCodeResponse = state.response;
final userCode = deviceCodeResponse.userCode;
final url = deviceCodeResponse.verificationUri;

return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 80, vertical: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Title
const Center(
child: Text(
'Log in',
style: TextStyle(
fontSize: 56,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: -1,
),
),
),
const Spacer(flex: 2),

// Main Content Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// LEFT SIDE - QR CODE
Expanded(
flex: 5,
child: Column(
children: [
const Text(
'Scan QR code',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 24),
const Text(
'To log in, scan the QR code below with your\nphone camera or a QR reader app',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white70,
height: 1.6,
),
),
const SizedBox(height: 48),

Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
child: QrImageView(
data: '$url?code=$userCode',
version: QrVersions.auto,
size: 220.0,
backgroundColor: Colors.white,
),
),
],
),
),

// MIDDLE - OR + VERTICAL LINE
Expanded(
flex: 2,
child: Center(
child: Stack(
alignment: Alignment.center,
children: [
// Vertical Divider Line
Container(
width: 2,
height: 260,
color: Colors.white24,
),

// OR Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black,
// background to hide line behind text
border: Border.all(
color: Colors.white24,
width: 1.5,
),
borderRadius: BorderRadius.circular(24),
),
child: const Text(
'OR',
style: TextStyle(
color: Colors.white60,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),

// RIGHT SIDE - WEBSITE LOGIN
Expanded(
flex: 5,
child: Padding(
padding: const EdgeInsets.only(left: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'To activate this smart TV',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 36),
const Text(
'1. On your phone or computer, go to:',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
const SizedBox(height: 16),
Text(
url,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 36),
const Text(
'2. Log in to your account',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
const SizedBox(height: 36),
const Text(
'3. Enter the following code:',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
const SizedBox(height: 16),

Text(
userCode,
style: TextStyle(
color: Color(0xFFFF9500),
fontSize: 28,
fontWeight: FontWeight.bold,
letterSpacing: 6,
),
),
],
),
),
),
],
),

const Spacer(flex: 2),

// FOOTER TEXT WITH LOADING INDICATOR
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'The page may take a few seconds to update after you have completed the steps on your device.',
style: TextStyle(
fontSize: 13,
color: Colors.white38,
),
),
const SizedBox(width: 16),
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white38,
),
),
),
],
),
),

const SizedBox(height: 30),
],
),
),
);
}

}

Output

Testing the Complete Flow

Step 1: Start Services


# Start Spring Cloud Gateway
cd device-flow-gateway
./mvnw spring-boot:run

Step 2: Run Flutter App

cd tv_device_flow
flutter run

Step 3: Test Authentication

  1. Launch the app on Android TV

2. Note the user code displayed

3. On your phone/laptop, visit the verification URL

4. Enter the code and authenticate

5. The TV app automatically receives tokens and navigates to the home screen

6. API calls now include the Bearer token

Troubleshooting Common Issues

“Invalid Client” Error

This typically indicates that your client configuration is incorrect. Verify:

  • The client ID matches exactly
  • OAuth 2.0 Device Authorization Grant is enabled in the client
  • The client is enabled and not archived

Polling Returns “authorization_pending” Indefinitely

Check that:

  • Users can access the verification URI
  • No network firewalls block access to Keycloak
  • The realm is active and accepting authentications
  • The user code hasn’t expired

Tokens Expire Too Quickly

Adjust token lifespans in your client settings:

  • Access Token Lifespan for API access duration
  • Refresh Token Max Reuse for token refresh policies
  • Client Session Idle for the maximum time between device usage

User Can’t Find Verification Page

Ensure:

  • The verification URI is clearly displayed
  • Consider providing a QR code for mobile users
  • Instructions are in the user’s preferred language
  • The URL is short and memorable, or use the complete URI with pre-filled code

Conclusion

🏁 Well done !!. Device Code Flow with Keycloak provides a secure, user-friendly way to authenticate smart devices without traditional input methods. By following this post, you can implement robust authentication for IoT devices, smart TVs, CLI tools, and other input-constrained applications.

The complete source code is available on GitHub.

Support me through GitHub Sponsors.

Thank you for reading!! See you in the next post.

References

👉Link to Medium blog

Related Posts