Skip to main content
Version: v0.1.0

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​

  1. HTTPS Only - Always use HTTPS in production
  2. Short Expiration - Keep access token lifetime short (1 hour)
  3. Secure Storage - Use httpOnly cookies or sessionStorage
  4. Token Rotation - Rotate refresh tokens
  5. Validate Tokens - Always validate JWT signature and expiration
  6. Rate Limiting - Implement rate limiting on auth endpoints
  7. 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​