| 1 | import Foundation |
| 2 | |
| 3 | /// A persisted credential — the access token plus optional refresh/id tokens |
| 4 | /// and expiry. Produced by the OIDC token exchange (and storable from a tenant |
| 5 | /// login), and what ``SmbCloudCredentialsStore`` persists. |
| 6 | public struct SmbCloudSession: Codable, Equatable, Hashable, Sendable { |
| 7 | public let accessToken: String |
| 8 | public let refreshToken: String? |
| 9 | public let idToken: String? |
| 10 | public let tokenType: String |
| 11 | public let expiresAt: Date? |
| 12 | public let scopes: [String] |
| 13 | |
| 14 | public init( |
| 15 | accessToken: String, |
| 16 | refreshToken: String? = nil, |
| 17 | idToken: String? = nil, |
| 18 | tokenType: String = "Bearer", |
| 19 | expiresAt: Date? = nil, |
| 20 | scopes: [String] = [] |
| 21 | ) { |
| 22 | self.accessToken = accessToken |
| 23 | self.refreshToken = refreshToken |
| 24 | self.idToken = idToken |
| 25 | self.tokenType = tokenType |
| 26 | self.expiresAt = expiresAt |
| 27 | self.scopes = scopes |
| 28 | } |
| 29 | |
| 30 | public var scope: String? { |
| 31 | scopes.isEmpty ? nil : scopes.joined(separator: " ") |
| 32 | } |
| 33 | |
| 34 | public var authorizationHeaderValue: String { |
| 35 | "\(tokenType) \(accessToken)" |
| 36 | } |
| 37 | |
| 38 | public var isExpired: Bool { |
| 39 | isExpired(leeway: 0) |
| 40 | } |
| 41 | |
| 42 | public func isExpired(leeway: TimeInterval = 0) -> Bool { |
| 43 | guard let expiresAt else { return false } |
| 44 | return expiresAt <= Date().addingTimeInterval(leeway) |
| 45 | } |
| 46 | |
| 47 | public func isValid(leeway: TimeInterval = 60) -> Bool { |
| 48 | isExpired(leeway: leeway) == false |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | extension SmbCloudSession { |
| 53 | /// Builds a session from an OIDC token exchange response. |
| 54 | public init(tokenResponse: OIDC.TokenResponse, now: Date = Date()) { |
| 55 | let expiresAt = tokenResponse.expiresIn.map { now.addingTimeInterval(TimeInterval($0)) } |
| 56 | let scopes = |
| 57 | tokenResponse.scope? |
| 58 | .split(separator: " ") |
| 59 | .map(String.init) |
| 60 | .smbCloudUniqued() ?? [] |
| 61 | self.init( |
| 62 | accessToken: tokenResponse.accessToken, |
| 63 | refreshToken: tokenResponse.refreshToken?.smbCloudNilIfEmpty, |
| 64 | idToken: tokenResponse.idToken?.smbCloudNilIfEmpty, |
| 65 | tokenType: tokenResponse.tokenType, |
| 66 | expiresAt: expiresAt, |
| 67 | scopes: scopes |
| 68 | ) |
| 69 | } |
| 70 | |
| 71 | /// A normalized copy (trimmed/deduped scopes, empty tokens dropped). |
| 72 | public func storedRepresentation() -> SmbCloudSession { |
| 73 | SmbCloudSession( |
| 74 | accessToken: accessToken, |
| 75 | refreshToken: refreshToken?.smbCloudNilIfEmpty, |
| 76 | idToken: idToken?.smbCloudNilIfEmpty, |
| 77 | tokenType: tokenType, |
| 78 | expiresAt: expiresAt, |
| 79 | scopes: scopes.smbCloudUniqued() |
| 80 | ) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | extension String { |
| 85 | var smbCloudNilIfEmpty: String? { |
| 86 | trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | extension Sequence where Element == String { |
| 91 | func smbCloudUniqued() -> [String] { |
| 92 | var seen = Set<String>() |
| 93 | return compactMap { value in |
| 94 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| 95 | guard !trimmed.isEmpty else { return nil } |
| 96 | return seen.insert(trimmed).inserted ? trimmed : nil |
| 97 | } |
| 98 | } |
| 99 | } |