diff --git a/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsSingleEc2Builder.kt b/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsSingleEc2Builder.kt index 996b4b0dc..24ffba846 100644 --- a/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsSingleEc2Builder.kt +++ b/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsSingleEc2Builder.kt @@ -280,7 +280,7 @@ public abstract class TerraformAwsSingleEc2Builder( "name" - wsDomain "type" - "AAAA" "ttl" - 300 - "records" - listOf(expression("aws_instance.ubuntu.ipv6_addresses")) + "records" - listOf(expression("aws_instance.ubuntu.ipv6_addresses[0]")) } } } @@ -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 @@ -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 """ diff --git a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/OauthProofEndpoints.kt b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/OauthProofEndpoints.kt index 87e978be0..2bac21b0e 100644 --- a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/OauthProofEndpoints.kt +++ b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/OauthProofEndpoints.kt @@ -64,6 +64,11 @@ public class OauthProofEndpoints( override val proofSigner: RuntimeDeferred = secretBasis.signer("proof"), override val proofExpiration: Duration = 1.hours, private val credentials: Runtime, + 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 { @@ -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 { diff --git a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/AppleJwtVerifier.kt b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/AppleJwtVerifier.kt index 0264bf84b..27ba0d8e3 100644 --- a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/AppleJwtVerifier.kt +++ b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/AppleJwtVerifier.kt @@ -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" } diff --git a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/ExternalProfile.kt b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/ExternalProfile.kt index b20a750fb..93a9b34cf 100644 --- a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/ExternalProfile.kt +++ b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/ExternalProfile.kt @@ -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, diff --git a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthProviderCredentialsApple.kt b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthProviderCredentialsApple.kt index 6fde9ea54..7a8ccc764 100644 --- a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthProviderCredentialsApple.kt +++ b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthProviderCredentialsApple.kt @@ -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 @@ -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" } } \ No newline at end of file diff --git a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthProviderInfo.kt b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthProviderInfo.kt index 173e234bf..8cbd5a791 100644 --- a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthProviderInfo.kt +++ b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthProviderInfo.kt @@ -179,6 +179,7 @@ public class OauthProviderInfo( } }.internalBody() 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() }, @@ -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) } @@ -235,6 +238,7 @@ public class OauthProviderInfo( } }.body() ExternalProfile( + id = response2.sub, email = response2.email, image = response2.picture, ) @@ -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, @@ -285,6 +290,7 @@ private suspend inline fun 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, @@ -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, ) diff --git a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ApiHttpHandler.ext.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ApiHttpHandler.ext.kt index d6812ec5f..08eb1575c 100644 --- a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ApiHttpHandler.ext.kt +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ApiHttpHandler.ext.kt @@ -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]. @@ -29,6 +31,7 @@ private data class ApiHttpHandlerData?, INPUT, override val successCode: HttpStatus = HttpStatus.OK, override val errorCases: List = emptyList(), override val examples: List> = emptyList(), + override val timeout: Duration, val implementation: suspend context(ServerRuntime) HttpAccess.(INPUT) -> OUTPUT, ) : ApiHttpHandler { context(server: ServerRuntime) @@ -63,6 +66,7 @@ public fun ?, INPUT, OUTPUT> explicitApiHttpHan successCode: HttpStatus = HttpStatus.OK, errorCases: List = emptyList(), examples: List> = emptyList(), + timeout: Duration = 30.seconds, implementation: suspend context(ServerRuntime) HttpAccess.(INPUT) -> OUTPUT, ): ApiHttpHandler = ApiHttpHandlerData( @@ -75,6 +79,7 @@ public fun ?, INPUT, OUTPUT> explicitApiHttpHan successCode, errorCases, examples, + timeout, implementation ) @@ -117,6 +122,7 @@ public inline fun ?, reified INPUT, reified OUT successCode: HttpStatus = HttpStatus.OK, errorCases: List = emptyList(), examples: List> = emptyList(), + timeout: Duration = 30.seconds, noinline implementation: suspend context(ServerRuntime) HttpAccess.(INPUT) -> OUTPUT, ): ApiHttpHandler = explicitApiHttpHandler( @@ -129,6 +135,7 @@ public inline fun ?, reified INPUT, reified OUT successCode, errorCases, examples, + timeout, implementation )