API Authentication
Complete guide to authentication and authorization in Ciyex EHR APIs.
Overview
Ciyex EHR uses JWT (JSON Web Tokens) for API authentication, integrated with Keycloak for identity management. This guide covers authentication flows, token management, and security best practices.
Authentication Flow
sequenceDiagram
participant Client
participant API as Ciyex API
participant KC as Keycloak
Client->>KC: POST /realms/master/protocol/openid-connect/token<br/>(username, password)
KC->>KC: Validate credentials
KC->>Client: Return tokens<br/>(access_token, refresh_token)
Client->>API: GET /api/patients<br/>Authorization: Bearer {access_token}
API->>API: Validate JWT signature
API->>API: Check expiration
API->>API: Extract user claims
API->>Client: Return patient data
Note over Client,API: Token expires after 1 hour
Client->>KC: POST /realms/master/protocol/openid-connect/token<br/>(refresh_token)
KC->>Client: Return new access_token
Login
Username/Password Login
POST https://aran-stg.zpoa.com/realms/master/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=password
&client_id=ciyex-app
&username=provider@example.com
&password=SecurePassword123!
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600,
"refresh_expires_in": 86400,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"not-before-policy": 0,
"session_state": "abc123",
"scope": "profile email"
}
Client Credentials Flow
For server-to-server communication:
POST https://aran-stg.zpoa.com/realms/master/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=ciyex-service
&client_secret=your-client-secret
Token Structure
Access Token (JWT)
{
"header": {
"alg": "RS256",
"typ": "JWT",
"kid": "key-id"
},
"payload": {
"exp": 1697472000,
"iat": 1697468400,
"jti": "unique-token-id",
"iss": "https://aran-stg.zpoa.com/realms/master",
"aud": "ciyex-app",
"sub": "user-uuid",
"typ": "Bearer",
"azp": "ciyex-app",
"session_state": "session-id",
"acr": "1",
"realm_access": {
"roles": ["PROVIDER", "USER"]
},
"resource_access": {
"ciyex-app": {
"roles": ["provider"]
}
},
"scope": "profile email",
"email_verified": true,
"name": "Dr. Jane Smith",
"preferred_username": "jane.smith",
"given_name": "Jane",
"family_name": "Smith",
"email": "jane.smith@example.com",
"groups": ["/practice_1"]
}
}
Making Authenticated Requests
JavaScript/TypeScript
// Store tokens
const tokens = {
accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
expiresAt: Date.now() + 3600000
};
// Make authenticated request
async function fetchPatients() {
const response = await fetch('http://localhost:8080/api/patients', {
headers: {
'Authorization': `Bearer ${tokens.accessToken}`,
'Content-Type': 'application/json'
}
});
if (response.status === 401) {
// Token expired, refresh it
await refreshAccessToken();
return fetchPatients(); // Retry
}
return response.json();
}
// Refresh token
async function refreshAccessToken() {
const response = await fetch(
'https://aran-stg.zpoa.com/realms/master/protocol/openid-connect/token',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: 'ciyex-app',
refresh_token: tokens.refreshToken
})
}
);
const data = await response.json();
tokens.accessToken = data.access_token;
tokens.refreshToken = data.refresh_token;
tokens.expiresAt = Date.now() + data.expires_in * 1000;
}
Python
import requests
import time
class CiyexClient:
def __init__(self, username, password):
self.base_url = "http://localhost:8080"
self.keycloak_url = "https://aran-stg.zpoa.com"
self.client_id = "ciyex-app"
self.tokens = None
self.login(username, password)
def login(self, username, password):
response = requests.post(
f"{self.keycloak_url}/realms/master/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": self.client_id,
"username": username,
"password": password
}
)
response.raise_for_status()
self.tokens = response.json()
self.tokens['expires_at'] = time.time() + self.tokens['expires_in']
def refresh_token(self):
response = requests.post(
f"{self.keycloak_url}/realms/master/protocol/openid-connect/token",
data={
"grant_type": "refresh_token",
"client_id": self.client_id,
"refresh_token": self.tokens['refresh_token']
}
)
response.raise_for_status()
self.tokens = response.json()
self.tokens['expires_at'] = time.time() + self.tokens['expires_in']
def get_headers(self):
if time.time() >= self.tokens['expires_at']:
self.refresh_token()
return {
"Authorization": f"Bearer {self.tokens['access_token']}",
"Content-Type": "application/json"
}
def get_patients(self):
response = requests.get(
f"{self.base_url}/api/patients",
headers=self.get_headers()
)
response.raise_for_status()
return response.json()
# Usage
client = CiyexClient("provider@example.com", "password")
patients = client.get_patients()
cURL
# Login
TOKEN_RESPONSE=$(curl -X POST \
'https://aran-stg.zpoa.com/realms/master/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=password' \
-d 'client_id=ciyex-app' \
-d 'username=provider@example.com' \
-d 'password=password')
ACCESS_TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token')
# Make authenticated request
curl -X GET 'http://localhost:8080/api/patients' \
-H "Authorization: Bearer $ACCESS_TOKEN"
Backend Token Validation
Spring Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${keycloak.auth-server-url}")
private String keycloakUrl;
@Value("${keycloak.realm}")
private String realm;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/**").authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
String jwkSetUri = keycloakUrl + "/realms/" + realm +
"/protocol/openid-connect/certs";
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
grantedAuthoritiesConverter
);
return jwtAuthenticationConverter;
}
}
Extract User Information
@Service
public class UserService {
public UserDetails getCurrentUser() {
Authentication authentication = SecurityContextHolder
.getContext()
.getAuthentication();
if (authentication instanceof JwtAuthenticationToken) {
JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
Jwt jwt = jwtAuth.getToken();
return UserDetails.builder()
.userId(jwt.getSubject())
.email(jwt.getClaimAsString("email"))
.name(jwt.getClaimAsString("name"))
.roles(extractRoles(jwt))
.groups(extractGroups(jwt))
.build();
}
throw new UnauthorizedException("No authentication found");
}
private List<String> extractRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null && realmAccess.containsKey("roles")) {
return (List<String>) realmAccess.get("roles");
}
return Collections.emptyList();
}
private List<String> extractGroups(Jwt jwt) {
return jwt.getClaimAsStringList("groups");
}
}
Authorization
Role-Based Access Control
@RestController
@RequestMapping("/api/patients")
public class PatientController {
@GetMapping
@PreAuthorize("hasRole('PROVIDER') or hasRole('NURSE')")
public ResponseEntity<?> getAllPatients() {
// Only providers and nurses can access
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> deletePatient(@PathVariable Long id) {
// Only admins can delete
}
}
Custom Authorization
@Service
public class PatientAuthorizationService {
@Autowired
private UserService userService;
public boolean canAccessPatient(Long patientId) {
UserDetails user = userService.getCurrentUser();
Patient patient = patientRepository.findById(patientId)
.orElseThrow();
// Check if user's organization matches patient's organization
return user.getOrganizationId().equals(patient.getOrganizationId());
}
}
@RestController
public class PatientController {
@GetMapping("/api/patients/{id}")
@PreAuthorize("@patientAuthorizationService.canAccessPatient(#id)")
public ResponseEntity<?> getPatient(@PathVariable Long id) {
// User can only access patients in their organization
}
}
Token Management
Token Storage (Frontend)
// Secure token storage
class TokenManager {
private static readonly ACCESS_TOKEN_KEY = 'access_token';
private static readonly REFRESH_TOKEN_KEY = 'refresh_token';
private static readonly EXPIRES_AT_KEY = 'expires_at';
static setTokens(accessToken: string, refreshToken: string, expiresIn: number) {
// Store in httpOnly cookie (preferred) or sessionStorage
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken);
sessionStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken);
sessionStorage.setItem(
this.EXPIRES_AT_KEY,
String(Date.now() + expiresIn * 1000)
);
}
static getAccessToken(): string | null {
return sessionStorage.getItem(this.ACCESS_TOKEN_KEY);
}
static getRefreshToken(): string | null {
return sessionStorage.getItem(this.REFRESH_TOKEN_KEY);
}
static isTokenExpired(): boolean {
const expiresAt = sessionStorage.getItem(this.EXPIRES_AT_KEY);
if (!expiresAt) return true;
return Date.now() >= parseInt(expiresAt);
}
static clearTokens() {
sessionStorage.removeItem(this.ACCESS_TOKEN_KEY);
sessionStorage.removeItem(this.REFRESH_TOKEN_KEY);
sessionStorage.removeItem(this.EXPIRES_AT_KEY);
}
}
Automatic Token Refresh
// Axios interceptor for automatic token refresh
import axios from 'axios';
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = TokenManager.getRefreshToken();
const response = await axios.post(
'https://aran-stg.zpoa.com/realms/master/protocol/openid-connect/token',
new URLSearchParams({
grant_type: 'refresh_token',
client_id: 'ciyex-app',
refresh_token: refreshToken!
})
);
const { access_token, refresh_token, expires_in } = response.data;
TokenManager.setTokens(access_token, refresh_token, expires_in);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return axios(originalRequest);
} catch (refreshError) {
TokenManager.clearTokens();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
Logout
Logout Request
POST https://aran-stg.zpoa.com/realms/master/protocol/openid-connect/logout
Content-Type: application/x-www-form-urlencoded
client_id=ciyex-app
&refresh_token={refresh_token}
Frontend Logout
async function logout() {
const refreshToken = TokenManager.getRefreshToken();
try {
await fetch(
'https://aran-stg.zpoa.com/realms/master/protocol/openid-connect/logout',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: 'ciyex-app',
refresh_token: refreshToken!
})
}
);
} finally {
TokenManager.clearTokens();
window.location.href = '/login';
}
}
Security Best Practices
- HTTPS Only - Always use HTTPS in production
- Short Expiration - Keep access token lifetime short (1 hour)
- Secure Storage - Use httpOnly cookies or sessionStorage
- Token Rotation - Rotate refresh tokens
- Validate Tokens - Always validate JWT signature and expiration
- Rate Limiting - Implement rate limiting on auth endpoints
- Audit Logging - Log all authentication events
Troubleshooting
Invalid Token
Error: 401 Unauthorized - Invalid token
Solutions:
# Verify token is not expired
jwt decode $ACCESS_TOKEN
# Check token signature
# Ensure Keycloak public key matches
# Verify issuer and audience
Token Refresh Failed
Error: 400 Bad Request - Invalid refresh token
Solutions:
- Refresh token may be expired (24 hours)
- User may have logged out
- Refresh token may have been revoked
- Re-authenticate user
Next Steps
- Keycloak Integration - Admin guide for Keycloak
- Security Best Practices - Security checklist
- Backend Architecture - Implementation details
- Frontend Architecture - Client detailsnd auth