Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ public abstract class TerraformAwsSingleEc2Builder<S : ServerBuilder>(
"name" - wsDomain
"type" - "AAAA"
"ttl" - 300
"records" - listOf(expression("aws_instance.ubuntu.ipv6_addresses"))
"records" - listOf(expression("aws_instance.ubuntu.ipv6_addresses[0]"))
}
}
}
Expand Down Expand Up @@ -590,6 +590,10 @@ REGION="$$applicationRegion"""",
// language="Shell Script"
appendLine(
$$"""

echo "[INFO] Reloading systemd configuration..."
systemctl daemon-reload

systemctl enable $$projectPrefix

# First-time deploy uses the same script that subsequent SSM-driven redeploys
Expand All @@ -599,8 +603,6 @@ echo "[INFO] Running first-time application deploy..."

# === Start Services ===
echo "[INFO] Starting services..."
systemctl daemon-reload

systemctl enable angie
systemctl restart angie
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public class OauthProofEndpoints(
override val proofSigner: RuntimeDeferred<Signer> = secretBasis.signer("proof"),
override val proofExpiration: Duration = 1.hours,
private val credentials: Runtime<OauthProviderCredentials>,
private val makeProof: suspend context(ServerRuntime, ProofMethod) (ExternalProfile) -> Proof =
{ profile ->
val email = profile.email ?: throw BadRequestException("No email was found for this profile.")
proofSigner.await().makeProof(property = "email", value = email)
},
private val continueUiAuthUrl: context(ServerRuntime) (Proof) -> String,
) : ServerBuilder(), ExternalProofMethod {

Expand All @@ -90,15 +95,7 @@ public class OauthProofEndpoints(
credentials = credentials,
) { response: OauthResponse, _: Uuid ->
val profile = provider.getProfile(response, credentials())
val email = profile.email ?: throw BadRequestException("No email was found for this profile.")
HttpResponse.redirectToGet(
continueUiAuthUrl(
proofSigner.await().makeProof(
property = "email",
value = email,
)
)
)
HttpResponse.redirectToGet(continueUiAuthUrl(makeProof(profile)))
}

public val openEndpoint: HttpHandler<*> = path.path("open").get bind HttpHandler {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ public object AppleJwtVerifier {

// Create a verification-only signer
return object : Signer {
override val generator = throw UnsupportedOperationException("This signer is verification-only")
override val generator get() = throw UnsupportedOperationException("This signer is verification-only")
override val verifier = publicKey.signatureVerifier()
override val name = "RS256"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable

@Serializable
public data class ExternalProfile(
val id: String? = null,
val email: String? = null,
val username: String? = null,
val name: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.lightningkite.lightningserver.runtime.now
import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.algorithms.EC
import dev.whyoleg.cryptography.algorithms.ECDSA
import dev.whyoleg.cryptography.algorithms.SHA256
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import kotlin.io.encoding.Base64
Expand Down Expand Up @@ -44,57 +45,47 @@ public data class OauthProviderCredentialsApple(
secret = generateJwt()
)

@OptIn(ExperimentalEncodingApi::class)
context(_: ServerRuntime)
public fun generateJwt(): String {
return buildString {
val withDefaults = Json { encodeDefaults = true; explicitNulls = false }
append(
Base64.UrlSafe.encode(withDefaults.encodeToString(buildJsonObject {
// put("typ", "JWT")
put("kid", keyId)
put("alg", "ES256")
}).toByteArray()).trimEnd('=')
)
append('.')
val issuedAt = now().minus(1.days)
append(
Base64.UrlSafe.encode(
withDefaults.encodeToString(
buildJsonObject {
put("iss", teamId)
put("iat", issuedAt.toEpochMilliseconds().div(1000))
put("exp", issuedAt.plus(5.days).toEpochMilliseconds().div(1000))
put("aud", "https://appleid.apple.com")
put("sub", serviceId)
}
).toByteArray()
).trimEnd('=')
)
val soFar = this.toString()
append('.')
val withDefaults = Json { encodeDefaults = true; explicitNulls = false }

// Parse the ECDSA P-256 private key and sign
// The cryptography library API: get ECDSA algorithm, then get key decoder with curve only
val ecdsaAlgorithm = CryptographyProvider.Default.get(ECDSA)
val privateKeyDecoder = ecdsaAlgorithm.privateKeyDecoder(EC.Curve.P256)
val privateKey =
privateKeyDecoder.decodeFromByteArrayBlocking(EC.PrivateKey.Format.PEM, keyString.toByteArray())
val header = Base64.UrlSafe.encode(withDefaults.encodeToString(buildJsonObject {
put("kid", keyId)
put("alg", "ES256")
}).toByteArray()).trimEnd('=')

// We only have private key, but KeyPair interface requires both. Create a minimal public key.
val publicKeyDecoder = ecdsaAlgorithm.publicKeyDecoder(EC.Curve.P256)
val publicKey = publicKeyDecoder.decodeFromByteArrayBlocking(EC.PublicKey.Format.DER, ByteArray(0))
val issuedAt = now().minus(1.days)
val payload = Base64.UrlSafe.encode(withDefaults.encodeToString(buildJsonObject {
put("iss", teamId)
put("iat", issuedAt.toEpochMilliseconds().div(1000))
put("exp", issuedAt.plus(5.days).toEpochMilliseconds().div(1000))
put("aud", "https://appleid.apple.com")
put("sub", serviceId)
}).toByteArray()).trimEnd('=')

@OptIn(dev.whyoleg.cryptography.CryptographyProviderApi::class)
val keyPair = object : ECDSA.KeyPair {
override val privateKey = privateKey
override val publicKey = publicKey
}
val signer = keyPair.ES256()
val unsignedToken = "$header.$payload"

append(
Base64.UrlSafe.encode(signer.signBlocking(soFar.toByteArray())).trimEnd('=')
)
}
// 1. Ensure valid PEM formatting for the Apple .p8 key
val formattedPem = if (keyString.contains("-----BEGIN")) keyString else """
-----BEGIN PRIVATE KEY-----
$keyString
-----END PRIVATE KEY-----
""".trimIndent()

// 2. Decode the private key
val ecdsaAlgorithm = CryptographyProvider.Default.get(ECDSA)
val privateKeyDecoder = ecdsaAlgorithm.privateKeyDecoder(EC.Curve.P256)
val privateKey = privateKeyDecoder.decodeFromByteArrayBlocking(
EC.PrivateKey.Format.PEM,
formattedPem.toByteArray()
)

// 3. Create a generator directly from privateKey (using RAW/IEEE_P1363 format for JWTs)
val generator = privateKey.signatureGenerator(SHA256, ECDSA.SignatureFormat.RAW)
val signature = generator.generateSignatureBlocking(unsignedToken.toByteArray())

val encodedSignature = Base64.UrlSafe.encode(signature).trimEnd('=')

return "$unsignedToken.$encodedSignature"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ public class OauthProviderInfo(
}
}.internalBody<GoogleResponse2>()
ExternalProfile(
id = response2.id,
email = if (response2.verified_email) response2.email else null,
image = response2.picture?.takeUnless { it.isEmpty() },
name = response2.name?.takeUnless { it.isEmpty() },
Expand Down Expand Up @@ -207,19 +208,21 @@ public class OauthProviderInfo(
val claimsJson = serverRuntime.externalSerialization.json.parseToJsonElement(
serverRuntime.externalSerialization.json.encodeToString(claims)
).jsonObject

val sub = claimsJson.get("sub")?.jsonPrimitive?.content
?: throw BadRequestException("Subject id must be present")

val emailVerified = claimsJson.get("email_verified")?.jsonPrimitive?.content?.toBooleanStrictOrNull()
?: claimsJson.get("email_verified")?.jsonPrimitive?.boolean
?: throw BadRequestException("Missing email_verified claim in Apple ID token")

if (!emailVerified) {
throw BadRequestException("Apple has not verified the email address.")
}
?: false

// Extract email from verified claims
val email = claimsJson.get("email")?.jsonPrimitive?.content
?: throw BadRequestException("No email found in verified Apple ID token")
// Email will be null on 2nd+ logins
val email = if (emailVerified) claimsJson.get("email")?.jsonPrimitive?.content else null

ExternalProfile(email = email)
ExternalProfile(
id = sub,
email = email
)
}
).also { all.add(it) }

Expand All @@ -235,6 +238,7 @@ public class OauthProviderInfo(
}
}.body()
ExternalProfile(
id = response2.sub,
email = response2.email,
image = response2.picture,
)
Expand Down Expand Up @@ -267,6 +271,7 @@ public class OauthProviderInfo(
if (primary.verified) primary.email else null
}
ExternalProfile(
id = user.id.toString(),
email = email,
username = user.login,
image = user.avatar_url,
Expand All @@ -285,6 +290,7 @@ private suspend inline fun <reified T> io.ktor.client.statement.HttpResponse.int

@Serializable
private data class GoogleResponse2(
val id: String? = null,
val verified_email: Boolean,
val email: String,
val picture: String? = null,
Expand All @@ -293,6 +299,7 @@ private data class GoogleResponse2(

@Serializable
private data class MicrosoftAccountInfo(
val sub: String? = null,
val email: String? = null,
val picture: String? = null,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import com.lightningkite.lightningserver.typed.sdk.functionCase
import com.lightningkite.services.database.HasId
import com.lightningkite.services.database.serializerOrContextual
import kotlinx.serialization.KSerializer
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

/**
* Internal data class implementation of [ApiHttpHandler].
Expand All @@ -29,6 +31,7 @@ private data class ApiHttpHandlerData<PATH : PathSpec, USER : HasId<*>?, INPUT,
override val successCode: HttpStatus = HttpStatus.OK,
override val errorCases: List<LSError> = emptyList(),
override val examples: List<ApiHttpHandler.Example<INPUT, OUTPUT>> = emptyList(),
override val timeout: Duration,
val implementation: suspend context(ServerRuntime) HttpAccess<PATH, USER>.(INPUT) -> OUTPUT,
) : ApiHttpHandler<PATH, USER, INPUT, OUTPUT> {
context(server: ServerRuntime)
Expand Down Expand Up @@ -63,6 +66,7 @@ public fun <PATH : PathSpec, USER : HasId<*>?, INPUT, OUTPUT> explicitApiHttpHan
successCode: HttpStatus = HttpStatus.OK,
errorCases: List<LSError> = emptyList(),
examples: List<ApiHttpHandler.Example<INPUT, OUTPUT>> = emptyList(),
timeout: Duration = 30.seconds,
implementation: suspend context(ServerRuntime) HttpAccess<PATH, USER>.(INPUT) -> OUTPUT,
): ApiHttpHandler<PATH, USER, INPUT, OUTPUT> =
ApiHttpHandlerData(
Expand All @@ -75,6 +79,7 @@ public fun <PATH : PathSpec, USER : HasId<*>?, INPUT, OUTPUT> explicitApiHttpHan
successCode,
errorCases,
examples,
timeout,
implementation
)

Expand Down Expand Up @@ -117,6 +122,7 @@ public inline fun <PATH : PathSpec, USER : HasId<*>?, reified INPUT, reified OUT
successCode: HttpStatus = HttpStatus.OK,
errorCases: List<LSError> = emptyList(),
examples: List<ApiHttpHandler.Example<INPUT, OUTPUT>> = emptyList(),
timeout: Duration = 30.seconds,
noinline implementation: suspend context(ServerRuntime) HttpAccess<PATH, USER>.(INPUT) -> OUTPUT,
): ApiHttpHandler<PATH, USER, INPUT, OUTPUT> =
explicitApiHttpHandler(
Expand All @@ -129,6 +135,7 @@ public inline fun <PATH : PathSpec, USER : HasId<*>?, reified INPUT, reified OUT
successCode,
errorCases,
examples,
timeout,
implementation
)

Expand Down
Loading