Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Deployment/e2e/applications/Ham/tenant.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ metadata:
spec:
silent_login: false
hosts:
- id.ham.test
- page.ham.test
- shop.ham.test
- api1.ham.test
Expand All @@ -26,7 +27,14 @@ spec:
- |
class UserLoginProvider {
auth = false;
constructor(credentials) { this.auth = credentials.username.endsWith("@example.com"); commit(true); }
constructor(credentials) {
if (credentials.grant_type === 'interceptor' && credentials.username != "allow@example.com") {
commit(false);
return;
}
this.auth = credentials.username.endsWith("@example.com");
commit(true);
}
get canLogin() { return this.auth; }
get userProfile() { return { name: "Test User" }; }
get role() { return "user"; }
Expand Down
19 changes: 19 additions & 0 deletions Deployment/e2e/uitsmijter-client.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,22 @@ spec:
tenantname: uitsmijter/ham
redirect_urls:
- 'https?://(.*\.)?ham.test(:8080)?/.*'

---
# Device Authorization Grant client used by terminal-login-test
apiVersion: "uitsmijter.io/v1"
kind: Client
metadata:
name: terminal-device-client
spec:
ident: f8b3c2a1-0d1e-4f8b-9c2a-3d4e5f6a7b8c
tenantname: uitsmijter/uitsmijter-tenant
redirect_urls:
- 'https?://(.*\.)?localhost(:8080)?/.*'
grant_types:
- device_code
scopes:
- access
device_grant_config:
expires_in: 1800
interval: 5
12 changes: 12 additions & 0 deletions Deployment/helm/uitsmijter/templates/crd-clients.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ spec:
type: boolean
secret:
type: string
device_grant_config:
type: object
properties:
expires_in:
type: integer
description: Lifetime in seconds of device_code and user_code (default 1800)
interval:
type: integer
description: Minimum polling interval in seconds (default 5)
verification_uri:
type: string
description: Override the verification URI shown to the user
required:
- ident
- tenantname
Expand Down
7 changes: 7 additions & 0 deletions Resources/Translations/de_DE.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
{
"ACTIVATE": {
"UI": {
"USER_CODE": "Gerätecode (z.B. ABCD-EFGH)",
"SUCCESS": "Ihr Gerät wurde erfolgreich autorisiert.",
"AUTHORIZE": "Gerät autorisieren"
}
},
"LOGIN": {
"UI": {
"LOGIN": "Anmelden",
Expand Down
7 changes: 7 additions & 0 deletions Resources/Translations/en_EN.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
{
"ACTIVATE": {
"UI": {
"USER_CODE": "Device Code (e.g. ABCD-EFGH)",
"SUCCESS": "Your device has been authorized successfully.",
"AUTHORIZE": "Authorize Device"
}
},
"LOGIN": {
"UI": {
"LOGIN": "Log In",
Expand Down
7 changes: 7 additions & 0 deletions Resources/Translations/pt_PT.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
{
"ACTIVATE": {
"UI": {
"USER_CODE": "Código do dispositivo (ex. ABCD-EFGH)",
"SUCCESS": "O seu dispositivo foi autorizado com sucesso.",
"AUTHORIZE": "Autorizar dispositivo"
}
},
"LOGIN": {
"UI": {
"LOGIN": "Iniciar sessão",
Expand Down
70 changes: 70 additions & 0 deletions Resources/Views/default/activate.leaf
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#(title)</title>
<link rel="stylesheet" href="css/uitsmijter.css">
<script type="application/javascript" src="js/uitsmijter.js"></script>
</head>
<body class="background">
<header>
<ul class="navigation">
<li><a href="#" id="clock">
<script type="application/javascript">
let clock = new SimpleClock("clock")
document.write(clock.currentTime)
</script>
</a></li>
</ul>
</header>
<main>
<div class="logo-box">
<img src="images/uitsmijter.svg">
</div>

#if(success):
<div class="login-box">
<div class="message" data-result="success">#t("ACTIVATE.UI.SUCCESS")</div>
</div>
#else:
<div class='login-box #isnotempty(error, "error")'>
<form action="/activate" method="post">
<div class="field">
<input type="text" id="user_code" name="user_code" value="#(userCode)"
placeholder='#t("ACTIVATE.UI.USER_CODE")'
autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false">
</div>
<div class="field">
<input type="text" id="username" name="username" placeholder='#t("LOGIN.UI.NAME")' autofocus>
</div>
<div class="field">
<input type="password" id="password" name="password" placeholder='#t("LOGIN.UI.PASSWORD")'>
</div>
#if(error != nil):
<div class="error" data-error="#(error)">#t(error)</div>
#endif

<button id="activateButton" type="submit" class="button-big" name="activate-button">
<span>#t("ACTIVATE.UI.AUTHORIZE")</span>
</button>
</form>
</div>
#endif
</main>

#if(tenant != nil && tenant.config.informations != nil):
<footer>
<ul class="footer-item">
#if(tenant.config.informations.imprint_url != nil):
<li><a href="#(tenant.config.informations.imprint_url)" data-type="imprint">Impressum</a></li>
#endif
#if(tenant.config.informations.privacy_url != nil):
<li><a href="#(tenant.config.informations.privacy_url)" data-type="privacy">Datenschutz</a></li>
#endif
</ul>
</footer>
#endif

</body>
</html>
2 changes: 1 addition & 1 deletion Sources/FoundationExtensions/String/String+Random.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public extension String {
///
/// - Parameter characterSet: A string containing all allowed characters
/// - Returns: A `RandomCharacterSet` with the specified characters
static func custom(_ characterSet: String) -> RandomCharacterSet {
public static func custom(_ characterSet: String) -> RandomCharacterSet {
RandomCharacterSet(value: characterSet)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
// Already expired, remove immediately
if storage.isEmpty == false {
let removed = storage.removeLast()
Log.debug("Removing code: \(removed.code.value), TTL: \(String(describing: removed.ttl))")
Log.debug("Removing code: \(removed.codeValue), TTL: \(String(describing: removed.ttl))")
gc()
}
return
Expand All @@ -64,7 +64,7 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
private func removeExpired() {
if storage.isEmpty == false {
let removed = storage.removeLast()
Log.debug("Removing code: \(removed.code.value), TTL: \(String(describing: removed.ttl))")
Log.debug("Removing code: \(removed.codeValue), TTL: \(String(describing: removed.ttl))")
gc()
}
}
Expand All @@ -76,12 +76,12 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
/// - Parameter session: The authorization session to store
/// - Throws: `AuthCodeStorageError.CODE_TAKEN` if a session with this code already exists
func set(authSession session: AuthSession) async throws {
if storage.contains(where: { $0.code.value == session.code.value }) {
if storage.contains(where: { $0.codeValue == session.codeValue }) {
throw AuthCodeStorageError.CODE_TAKEN
}
Log.debug(
"""
Storing new session - Type: \(session.type.rawValue), \
Storing new session - Type: \(session.sessionType.rawValue), \
Tenant: \(session.payload?.tenant ?? "nil"), \
Subject: \(session.payload?.subject.value ?? "nil"), \
TTL: \(String(describing: session.ttl))
Expand All @@ -95,14 +95,14 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
/// Retrieves an authorization session by code value.
///
/// - Parameters:
/// - type: The type of authorization code (code or refresh)
/// - type: The type of authorization code (code, refresh, or device)
/// - value: The authorization code value to look up
/// - remove: If true, removes the session after retrieval (single-use enforcement)
/// - Returns: The authorization session if found, nil otherwise
func get(type: AuthSession.CodeType, codeValue value: String, remove: Bool? = false) async -> AuthSession? {
let session = storage.first(where: { $0.code.value == value && $0.type == type })
func get(type: AuthSessionType, codeValue value: String, remove: Bool? = false) async -> AuthSession? {
let session = storage.first(where: { $0.codeValue == value && $0.sessionType == type })
if remove ?? false {
storage.removeAll(where: { $0.code.value == value && $0.type == type })
storage.removeAll(where: { $0.codeValue == value && $0.sessionType == type })
}
return session
}
Expand Down Expand Up @@ -142,11 +142,11 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
/// Deletes an authorization session by code value.
///
/// - Parameters:
/// - type: The type of authorization code (code or refresh)
/// - type: The type of authorization code (code, refresh, or device)
/// - value: The authorization code value to delete
/// - Throws: Can throw errors from storage operations
func delete(type: AuthSession.CodeType, codeValue value: String) async throws {
storage.removeAll(where: { $0.code.value == value && $0.type == type })
func delete(type: AuthSessionType, codeValue value: String) async throws {
storage.removeAll(where: { $0.codeValue == value && $0.sessionType == type })
}

/// Removes all authorization sessions for a specific user.
Expand All @@ -164,7 +164,7 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
if matches {
Log.debug(
"""
Matching session found - Type: \(session.type.rawValue), \
Matching session found - Type: \(session.sessionType.rawValue), \
Tenant: \(session.payload?.tenant ?? "nil"), \
Subject: \(session.payload?.subject.value ?? "nil")
"""
Expand All @@ -184,15 +184,15 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
/// - tenant: The tenant to count sessions for
/// - type: The type of sessions to count (e.g., .refresh for long-lived sessions)
/// - Returns: The number of sessions matching the criteria
func count(tenant: Tenant, type: AuthSession.CodeType) async -> Int {
let matchingSessions = storage.filter { $0.payload?.tenant == tenant.name && $0.type == type }
func count(tenant: Tenant, type: AuthSessionType) async -> Int {
let matchingSessions = storage.filter { $0.payload?.tenant == tenant.name && $0.sessionType == type }
Log.debug("Count called for tenant: \(tenant.name), type: \(type.rawValue) - found \(matchingSessions.count)")

// Log details of each matching session for debugging
for session in matchingSessions {
Log.debug(
"""
Session in count - Type: \(session.type.rawValue), \
Session in count - Type: \(session.sessionType.rawValue), \
Tenant: \(session.payload?.tenant ?? "nil"), \
Subject: \(session.payload?.subject.value ?? "nil")
"""
Expand All @@ -208,21 +208,21 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
/// - client: The client to count sessions for
/// - type: The type of sessions to count (e.g., .refresh for long-lived sessions)
/// - Returns: The number of sessions matching the criteria
func count(client: UitsmijterClient, type: AuthSession.CodeType) async -> Int {
func count(client: UitsmijterClient, type: AuthSessionType) async -> Int {
let clientIdString = client.config.ident.uuidString
let matchingSessions = storage.filter { session in
// Match by audience (client_id) in the payload
guard let payload = session.payload else { return false }
let audienceMatches = payload.audience.value.contains(clientIdString)
return audienceMatches && session.type == type
return audienceMatches && session.sessionType == type
}
Log.debug("Count called for client: \(client.name), type: \(type.rawValue) - found \(matchingSessions.count)")

// Log details of each matching session for debugging
for session in matchingSessions {
Log.debug(
"""
Session in count - Type: \(session.type.rawValue), \
Session in count - Type: \(session.sessionType.rawValue), \
Client: \(session.payload?.audience.value.joined(separator: ",") ?? "nil"), \
Subject: \(session.payload?.subject.value ?? "nil")
"""
Expand All @@ -232,6 +232,56 @@ actor MemoryAuthCodeStorage: AuthCodeStorageProtocol {
return matchingSessions.count
}

/// Retrieves a device session by the short user code entered at the activation page.
///
/// - Parameter userCode: The user-facing code (e.g. "ABCD-EFGH")
/// - Returns: The matching device session, or nil if not found
func getDevice(byUserCode userCode: String) async -> AuthSession? {
storage.first { authSession in
guard case .device(let deviceSession) = authSession else { return false }
return deviceSession.userCode == userCode
}
}

/// Updates an existing device session's status, payload, and last-polled timestamp.
///
/// - Parameters:
/// - deviceCode: The device code identifying the session
/// - newStatus: The new device grant status
/// - payload: The authorized user payload (nil when still pending)
/// - lastPolledAt: The time the device last polled the token endpoint
/// - Throws: `AuthCodeStorageError.KEY_ERROR` if no matching session is found
func updateDevice(
deviceCode: String,
newStatus: DeviceGrantStatus,
payload: Payload?,
lastPolledAt: Date?
) async throws {
guard let index = storage.firstIndex(where: { authSession in
guard case .device(let session) = authSession else { return false }
return session.deviceCode.value == deviceCode
}) else {
throw AuthCodeStorageError.KEY_ERROR
}

guard case .device(let existing) = storage[index] else {
throw AuthCodeStorageError.KEY_ERROR
}

let updated = DeviceSession(
clientId: existing.clientId,
deviceCode: existing.deviceCode,
userCode: existing.userCode,
scopes: existing.scopes,
payload: payload,
status: newStatus,
lastPolledAt: lastPolledAt,
ttl: existing.ttl,
generated: existing.generated
)
storage[index] = .device(updated)
}

/// Checks if the storage backend is healthy and operational.
///
/// - Returns: Always returns true for memory storage (always available)
Expand Down
Loading
Loading