|
| 1 | +package net.aholbrook.paseto |
| 2 | + |
| 3 | +import kotlinx.serialization.KSerializer |
| 4 | +import kotlinx.serialization.SerializationException |
| 5 | +import kotlinx.serialization.descriptors.SerialDescriptor |
| 6 | +import kotlinx.serialization.descriptors.buildClassSerialDescriptor |
| 7 | +import kotlinx.serialization.encoding.Decoder |
| 8 | +import kotlinx.serialization.encoding.Encoder |
| 9 | +import kotlinx.serialization.json.Json |
| 10 | +import kotlinx.serialization.json.JsonDecoder |
| 11 | +import kotlinx.serialization.json.JsonEncoder |
| 12 | +import kotlinx.serialization.json.JsonObject |
| 13 | +import kotlinx.serialization.json.JsonPrimitive |
| 14 | +import kotlinx.serialization.json.buildJsonObject |
| 15 | +import kotlinx.serialization.json.contentOrNull |
| 16 | +import kotlinx.serialization.json.jsonObject |
| 17 | +import kotlinx.serialization.json.jsonPrimitive |
| 18 | +import net.aholbrook.paseto.exception.FooterExceedsMaxDepthException |
| 19 | +import net.aholbrook.paseto.exception.FooterExceedsMaxKeysException |
| 20 | +import net.aholbrook.paseto.exception.FooterExceedsMaxLengthException |
| 21 | +import net.aholbrook.paseto.exception.FooterJsonParseException |
| 22 | +import net.aholbrook.paseto.protocol.jsonCountDepthAndKeys |
| 23 | + |
| 24 | +/** |
| 25 | + * Determines how token footer text is interpreted when decoding. |
| 26 | + * |
| 27 | + * - [AUTO]: Treat object-like footer text (`{...}`) as JSON claims when possible; |
| 28 | + * otherwise fall back to a plain string footer. |
| 29 | + * - [CLAIMS]: Require a valid JSON claims footer and fail if parsing/validation fails. |
| 30 | + * - [STRING]: Always treat footer text as plain string data (no claims parsing). |
| 31 | + */ |
| 32 | +enum class FooterParseMode { |
| 33 | + /** |
| 34 | + * Parse object-like footer text as claims, throwing on depth/key limit violations and falling back to |
| 35 | + * [StringFooter] on parse failure. |
| 36 | + */ |
| 37 | + AUTO, |
| 38 | + |
| 39 | + /** |
| 40 | + * Require footer to be valid claims JSON; throw on parse/validation errors. |
| 41 | + */ |
| 42 | + CLAIMS, |
| 43 | + |
| 44 | + /** |
| 45 | + * Always decode footer as plain string. |
| 46 | + */ |
| 47 | + STRING, |
| 48 | +} |
| 49 | + |
| 50 | +internal data class FooterOptions( |
| 51 | + val parseMode: FooterParseMode = FooterParseMode.AUTO, |
| 52 | + val maxLength: Int = 8192, |
| 53 | + val maxDepth: Int = 2, |
| 54 | + val maxKeys: Int = 512, |
| 55 | +) |
| 56 | + |
| 57 | +@PasetoDslMarker |
| 58 | +class FooterOptionsBuilder @PublishedApi internal constructor() { |
| 59 | + var parseMode: FooterParseMode = FooterParseMode.AUTO |
| 60 | + var maxLength: Int = 8192 |
| 61 | + var maxDepth: Int = 2 |
| 62 | + var maxKeys: Int = 512 |
| 63 | + |
| 64 | + internal fun build(): FooterOptions = FooterOptions( |
| 65 | + parseMode = parseMode, |
| 66 | + maxLength = maxLength, |
| 67 | + maxDepth = maxDepth, |
| 68 | + maxKeys = maxKeys, |
| 69 | + ) |
| 70 | +} |
| 71 | + |
| 72 | +internal fun Json.encodeFooter(footerOptions: FooterOptions, footer: PasetoFooter): String { |
| 73 | + val encoded = when (footer) { |
| 74 | + is ClaimFooter -> encodeToString(ClaimFooterSerializer, footer) |
| 75 | + is StringFooter -> footer.value |
| 76 | + } |
| 77 | + |
| 78 | + footerOptions.validateFooter(encoded) |
| 79 | + return encoded |
| 80 | +} |
| 81 | + |
| 82 | +internal fun Json.decodeFooter(footerOptions: FooterOptions, footer: String): PasetoFooter { |
| 83 | + footerOptions.validateFooter(footer) |
| 84 | + |
| 85 | + return when (footerOptions.parseMode) { |
| 86 | + FooterParseMode.AUTO -> try { |
| 87 | + decodeFromString(ClaimFooterSerializer, footer) |
| 88 | + } catch (_: Exception) { |
| 89 | + StringFooter(footer) |
| 90 | + } |
| 91 | + |
| 92 | + FooterParseMode.CLAIMS -> try { |
| 93 | + decodeFromString(ClaimFooterSerializer, footer) |
| 94 | + } catch (ex: Exception) { |
| 95 | + throw FooterJsonParseException(ex.message, ex) |
| 96 | + } |
| 97 | + |
| 98 | + FooterParseMode.STRING -> StringFooter(footer) |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +private fun String.isJsonObject(): Boolean = trim().let { it.startsWith("{") && it.endsWith("}") } |
| 103 | + |
| 104 | +private fun FooterOptions.validateFooter(footer: String?) { |
| 105 | + if (footer == null) return |
| 106 | + |
| 107 | + if (footer.length > maxLength) throw FooterExceedsMaxLengthException(footer.length, maxLength) |
| 108 | + if (parseMode == FooterParseMode.STRING) return |
| 109 | + if (parseMode == FooterParseMode.AUTO && !footer.isJsonObject()) return |
| 110 | + |
| 111 | + val (depth, keys) = jsonCountDepthAndKeys(footer) |
| 112 | + if (depth > maxDepth) throw FooterExceedsMaxDepthException(depth, maxDepth) |
| 113 | + if (keys > maxKeys) throw FooterExceedsMaxKeysException(keys, maxKeys) |
| 114 | +} |
| 115 | + |
| 116 | +internal object ClaimFooterSerializer : KSerializer<ClaimFooter> { |
| 117 | + private val reserved = setOf("kid", "wpk") |
| 118 | + |
| 119 | + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClaimFooter") |
| 120 | + |
| 121 | + override fun deserialize(decoder: Decoder): ClaimFooter { |
| 122 | + when (val element = (decoder as JsonDecoder).decodeJsonElement()) { |
| 123 | + is JsonObject -> { |
| 124 | + fun take(name: String) = element.jsonObject[name]?.jsonPrimitive?.contentOrNull |
| 125 | + |
| 126 | + val claims = buildJsonObject { |
| 127 | + element.jsonObject.filterNot { it.key in reserved }.forEach { put(it.key, it.value) } |
| 128 | + } |
| 129 | + |
| 130 | + return ClaimFooter( |
| 131 | + keyId = take("kid"), |
| 132 | + wrappedKey = take("wpk"), |
| 133 | + claims = claims.toClaim() as ClaimObject, |
| 134 | + ) |
| 135 | + } |
| 136 | + |
| 137 | + else -> throw SerializationException("expected object, got $element") |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + override fun serialize(encoder: Encoder, value: ClaimFooter) { |
| 142 | + val output = encoder as JsonEncoder |
| 143 | + val element = buildJsonObject { |
| 144 | + value.keyId?.let { put("kid", JsonPrimitive(it)) } |
| 145 | + value.wrappedKey?.let { put("wpk", JsonPrimitive(it)) } |
| 146 | + |
| 147 | + (value.claims.toJson() as JsonObject) |
| 148 | + .filterNot { it.key in reserved } |
| 149 | + .forEach { (k, v) -> put(k, v) } |
| 150 | + } |
| 151 | + output.encodeJsonElement(element) |
| 152 | + } |
| 153 | +} |
0 commit comments