Skip to content

Commit cba59f6

Browse files
committed
Cleanup.
1 parent 48d9d33 commit cba59f6

38 files changed

Lines changed: 581 additions & 569 deletions

paseto/.api-validation/paseto.api

Lines changed: 133 additions & 138 deletions
Large diffs are not rendered by default.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package net.aholbrook.paseto
2+
3+
import kotlinx.serialization.json.JsonObject
4+
import kotlin.contracts.ExperimentalContracts
5+
import kotlin.contracts.InvocationKind
6+
import kotlin.contracts.contract
7+
8+
sealed interface PasetoFooter
9+
10+
@JvmInline
11+
value class StringFooter internal constructor(val value: String) : PasetoFooter
12+
13+
@ConsistentCopyVisibility
14+
data class ClaimFooter internal constructor(
15+
val keyId: String?, // kid
16+
val wrappedKey: String?, // wpk
17+
val claims: ClaimObject,
18+
) : PasetoFooter
19+
20+
@PasetoDslMarker
21+
class ClaimFooterBuilder @PublishedApi internal constructor() {
22+
var keyId: String? = null // kid
23+
var wrappedKey: String? = null // wpk
24+
private var claims: ClaimObject = ClaimObject()
25+
26+
@OptIn(ExperimentalContracts::class)
27+
fun claims(init: ClaimObjectBuilder.() -> Unit) {
28+
contract { callsInPlace(init, InvocationKind.EXACTLY_ONCE) }
29+
30+
val builder = ClaimObjectBuilder()
31+
builder.init()
32+
claims = builder.build()
33+
}
34+
35+
fun claims(claims: ClaimObject) {
36+
this.claims = claims
37+
}
38+
39+
@PublishedApi
40+
internal fun build(): ClaimFooter = ClaimFooter(
41+
keyId = keyId,
42+
wrappedKey = wrappedKey,
43+
claims = claims,
44+
)
45+
}
46+
47+
fun footer(footer: String) = StringFooter(footer)
48+
49+
@OptIn(ExperimentalContracts::class)
50+
inline fun footer(init: ClaimFooterBuilder.() -> Unit): ClaimFooter {
51+
contract { callsInPlace(init, InvocationKind.EXACTLY_ONCE) }
52+
return ClaimFooterBuilder().apply(init).build()
53+
}
54+
55+
/**
56+
* A Paseto footer extracted from a token which has not been cryptographically verified. It is therefore possible that
57+
* a [TaintedPasetoFooter] has been tampered with.
58+
*
59+
* This is useful when the footer is used to carry information required to verify the key, like a key id (kid) claim.
60+
*/
61+
sealed interface TaintedPasetoFooter
62+
63+
@JvmInline
64+
value class TaintedStringFooter(val value: String) : TaintedPasetoFooter
65+
66+
@ConsistentCopyVisibility
67+
data class TaintedClaimFooter internal constructor(
68+
val keyId: String?, // kid
69+
val wrappedKey: String?, // wpk
70+
val claims: ClaimObject,
71+
) : TaintedPasetoFooter
72+
73+
/**
74+
* Converts a [PasetoFooter] to it's [TaintedPasetoFooter] variant.
75+
*
76+
* This can be used to compare an [TaintedClaimFooter] against a [ClaimFooter] built using the [footer] builder.
77+
* @receiver A [PasetoFooter] instance to turn taint.
78+
* @return A [TaintedPasetoFooter] representation of the given [PasetoFooter].
79+
*/
80+
fun PasetoFooter.taint(): TaintedPasetoFooter? = when (this) {
81+
is ClaimFooter -> TaintedClaimFooter(keyId, wrappedKey, claims)
82+
is StringFooter -> TaintedStringFooter(value)
83+
}
84+
85+
/**
86+
* Escape hatch for direct access to the footer's claims as a [JsonObject].
87+
*
88+
* This is an internal API because it couples the caller to the `kotlinx.serialization` JSON implementation.
89+
* It may change or be removed without notice if the underlying serialization strategy changes.
90+
*/
91+
@InternalApi
92+
fun ClaimFooter.claimsJson(): JsonObject = claims.toJson() as JsonObject
93+
94+
/**
95+
* Escape hatch for direct access to the footer's claims as a [JsonObject].
96+
*
97+
* This is an internal API because it couples the caller to the `kotlinx.serialization` JSON implementation.
98+
* It may change or be removed without notice if the underlying serialization strategy changes.
99+
*/
100+
@InternalApi
101+
fun TaintedClaimFooter.claimsJson(): JsonObject = claims.toJson() as JsonObject
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
}

paseto/src/main/kotlin/net/aholbrook/paseto/Serde.kt renamed to paseto/src/main/kotlin/net/aholbrook/paseto/PasetoTokenSerializer.kt

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package net.aholbrook.paseto
22

33
import kotlinx.serialization.KSerializer
4-
import kotlinx.serialization.SerializationException
54
import kotlinx.serialization.descriptors.SerialDescriptor
65
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
76
import kotlinx.serialization.encoding.Decoder
@@ -20,12 +19,12 @@ import java.time.format.DateTimeFormatter
2019

2120
private val RFC3339_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC)
2221

23-
internal object PasetoTokenSerializer : KSerializer<PasetoToken> {
22+
internal object PasetoTokenSerializer : KSerializer<Token> {
2423
private val reserved = setOf("iss", "sub", "aud", "exp", "nbf", "iat", "jti")
2524

2625
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PasetoToken")
2726

28-
override fun deserialize(decoder: Decoder): PasetoToken {
27+
override fun deserialize(decoder: Decoder): Token {
2928
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
3029

3130
fun take(name: String) = obj[name]?.jsonPrimitive?.contentOrNull
@@ -36,7 +35,7 @@ internal object PasetoTokenSerializer : KSerializer<PasetoToken> {
3635
obj.filterNot { it.key in reserved }.forEach { put(it.key, it.value) }
3736
}
3837

39-
return PasetoToken(
38+
return Token(
4039
issuer = take("iss"),
4140
subject = take("sub"),
4241
audience = take("aud"),
@@ -49,7 +48,7 @@ internal object PasetoTokenSerializer : KSerializer<PasetoToken> {
4948
)
5049
}
5150

52-
override fun serialize(encoder: Encoder, value: PasetoToken) {
51+
override fun serialize(encoder: Encoder, value: Token) {
5352
val output = encoder as JsonEncoder
5453

5554
val obj = buildJsonObject {
@@ -75,42 +74,3 @@ internal object PasetoTokenSerializer : KSerializer<PasetoToken> {
7574
output.encodeJsonElement(obj)
7675
}
7776
}
78-
79-
internal object ClaimFooterSerializer : KSerializer<ClaimFooter> {
80-
private val reserved = setOf("kid", "wpk")
81-
82-
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClaimFooter")
83-
84-
override fun deserialize(decoder: Decoder): ClaimFooter {
85-
when (val element = (decoder as JsonDecoder).decodeJsonElement()) {
86-
is JsonObject -> {
87-
fun take(name: String) = element.jsonObject[name]?.jsonPrimitive?.contentOrNull
88-
89-
val claims = buildJsonObject {
90-
element.jsonObject.filterNot { it.key in reserved }.forEach { put(it.key, it.value) }
91-
}
92-
93-
return ClaimFooter(
94-
keyId = take("kid"),
95-
wrappedKey = take("wpk"),
96-
claims = claims.toClaim() as ClaimObject,
97-
)
98-
}
99-
100-
else -> throw SerializationException("expected object, got $element")
101-
}
102-
}
103-
104-
override fun serialize(encoder: Encoder, value: ClaimFooter) {
105-
val output = encoder as JsonEncoder
106-
val element = buildJsonObject {
107-
value.keyId?.let { put("kid", JsonPrimitive(it)) }
108-
value.wrappedKey?.let { put("wpk", JsonPrimitive(it)) }
109-
110-
(value.claims.toJson() as JsonObject)
111-
.filterNot { it.key in reserved }
112-
.forEach { (k, v) -> put(k, v) }
113-
}
114-
output.encodeJsonElement(element)
115-
}
116-
}

0 commit comments

Comments
 (0)