Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth implementation. #91

Merged
merged 7 commits into from
Oct 13, 2024
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
target/
build/
out/
oauth.json
secrets.json
jwt.txt
/local.properties
47 changes: 47 additions & 0 deletions auth/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
id("module.publications")
}

kotlin {
jvmToolchain(11)

jvm { withJava() }
iosX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()

sourceSets {
commonMain.dependencies {
implementation(project(":core"))
implementation(libs.ktor.core)
implementation(libs.khttpclient)
implementation(libs.datetime)
implementation(libs.coroutines.core)
implementation(libs.serialization.json)

implementation(project.dependencies.platform(libs.cryptography.bom))
implementation(libs.cryptography.core)
}

appleMain.dependencies {
implementation(libs.cryptography.openssl)
}

// for test (kotlin/jvm)
jvmTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotest.junit5)
implementation(libs.kotest.assertions)
implementation(libs.cryptography.jdk)
}
}
}


tasks.named<Test>("jvmTest") {
useJUnitPlatform()
}
10 changes: 10 additions & 0 deletions auth/src/commonMain/kotlin/work/socialhub/kbsky/auth/Auth.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package work.socialhub.kbsky.auth

import work.socialhub.kbsky.auth.api.OAuthResource
import work.socialhub.kbsky.auth.api.WellKnownResource

interface Auth {

fun wellKnown(): WellKnownResource
fun oauth(): OAuthResource
}
13 changes: 13 additions & 0 deletions auth/src/commonMain/kotlin/work/socialhub/kbsky/auth/AuthConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package work.socialhub.kbsky.auth

import work.socialhub.kbsky.domain.Service.BSKY_SOCIAL

class AuthConfig {

var pdsServer = BSKY_SOCIAL.uri
var authorizationServer = BSKY_SOCIAL.uri

var tokenEndpoint = "${BSKY_SOCIAL.uri}oauth/token"
var authorizationEndpoint = "${BSKY_SOCIAL.uri}oauth/authorize"
var pushedAuthorizationRequestEndpoint = "${BSKY_SOCIAL.uri}oauth/par"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package work.socialhub.kbsky.auth

import work.socialhub.kbsky.auth.internal._Auth

object AuthFactory {

fun instance(pdsUri: String): Auth {
return _Auth(AuthConfig().also {
it.pdsServer = pdsUri
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package work.socialhub.kbsky.auth

import kotlinx.serialization.Serializable

@Serializable
class OAuthContext {

var clientId: String? = null

/** ECDSA P256 DER Base64 key */
var publicKey: String? = null
var privateKey: String? = null

var dPoPNonce: String? = null

/** Following values required during OAuth */
var redirectUri: String? = null
var codeVerifier: String? = null
var state: String? = null
}

110 changes: 110 additions & 0 deletions auth/src/commonMain/kotlin/work/socialhub/kbsky/auth/OAuthProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package work.socialhub.kbsky.auth

import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.algorithms.asymmetric.EC
import dev.whyoleg.cryptography.algorithms.asymmetric.ECDSA
import dev.whyoleg.cryptography.algorithms.digest.SHA256
import io.ktor.http.Url
import kotlinx.serialization.Serializable
import work.socialhub.kbsky.auth.helper.OAuthHelper
import work.socialhub.kbsky.auth.helper.OAuthHelper.extractDPoPNonce
import work.socialhub.kbsky.internal.share._InternalUtility.fromJson
import work.socialhub.khttpclient.HttpRequest
import work.socialhub.khttpclient.HttpResponse
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

class OAuthProvider(
val accessTokenJwt: String,
val context: OAuthContext,
) : AuthProvider {

override val did: String
get() = jwt.sub

override val pdsDid: String
get() = jwt.aud

val jwt: Jwt
get() {
val encodedJson = accessTokenJwt
.split("\\.".toRegex())
.dropLastWhile { it.isEmpty() }
.toTypedArray()[1]

@OptIn(ExperimentalEncodingApi::class)
val decodedJson = Base64.Default.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(encodedJson)
return fromJson<Jwt>(decodedJson.decodeToString())
}

@Serializable
class Jwt {
lateinit var scope: String
lateinit var sub: String
lateinit var aud: String
lateinit var iss: String
var iat: Int = -1
var exp: Int = -1
}


@OptIn(ExperimentalEncodingApi::class)
override fun beforeRequestHook(
method: String,
request: HttpRequest,
) {
val publicKeyXY = OAuthHelper.extractXYFromPublicKey(
Base64.decode(context.publicKey!!)
)

val dPoPHeader = OAuthHelper.makeDPoPHeader(
clientId = context.clientId!!,
endpoint = request.getUrl(),
method = method,
dPoPNonce = context.dPoPNonce!!,
accessToken = accessTokenJwt,
authorizationServer = jwt.iss,
publicKeyWAffineX = publicKeyXY.first,
publicKeyWAffineY = publicKeyXY.second,
sign = { jwtMessage ->

val privateKey = CryptographyProvider.Default.get(ECDSA)
.privateKeyDecoder(EC.Curve.P256)
.decodeFromBlocking(
EC.PrivateKey.Format.DER,
Base64.decode(context.privateKey!!)
)

privateKey.signatureGenerator(SHA256)
.generateSignatureBlocking(jwtMessage.encodeToByteArray())
}
)

request.header("Authorization", "DPoP $accessTokenJwt")
request.header("DPoP", dPoPHeader)
}


override fun afterRequestHook(
method: String,
request: HttpRequest,
response: HttpResponse
) {
response.extractDPoPNonce(context)
}

fun HttpRequest.getUrl(): String {
return url?.let {
Url(it).let { u ->
"${u.protocol.name}://${u.host}:${u.port}${u.encodedPath}"
}
} ?: let {
val p = if (path!!.startsWith("/")) path else "/${path}"
port?.let {
"${schema}://${host}:${port}${p}"
} ?: let {
"${schema}://${host}${p}"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package work.socialhub.kbsky.auth.api

import work.socialhub.kbsky.api.entity.share.Response
import work.socialhub.kbsky.auth.OAuthContext
import work.socialhub.kbsky.auth.api.entity.oauth.BuildAuthorizationUrlRequest
import work.socialhub.kbsky.auth.api.entity.oauth.OAuthPushedAuthorizationRequest
import work.socialhub.kbsky.auth.api.entity.oauth.OAuthPushedAuthorizationResponse
import work.socialhub.kbsky.auth.api.entity.oauth.OAuthTokenRequest
import work.socialhub.kbsky.auth.api.entity.oauth.OAuthTokenResponse

interface OAuthResource {

/**
* Step
*/
fun pushedAuthorizationRequest(
context: OAuthContext,
request: OAuthPushedAuthorizationRequest
): Response<OAuthPushedAuthorizationResponse>

/**
* Step
*/
fun buildAuthorizationUrl(
context: OAuthContext,
request: BuildAuthorizationUrlRequest
): String


/**
* Step
*/
fun tokenRequest(
context: OAuthContext,
request: OAuthTokenRequest,
): Response<OAuthTokenResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package work.socialhub.kbsky.auth.api

import work.socialhub.kbsky.api.entity.share.Response
import work.socialhub.kbsky.auth.api.entity.wellknown.WellKnownOAuthAuthorizationServerResponse
import work.socialhub.kbsky.auth.api.entity.wellknown.WellKnownOAuthProtectedResourceResponse

interface WellKnownResource {

/**
* Get OAuth Protected Resource
* https://oyster.us-east.host.bsky.network/.well-known/oauth-protected-resource
*/
fun oAuthProtectedResource()
: Response<WellKnownOAuthProtectedResourceResponse>


/**
* Get OAuth authorization server
* https://bsky.social/.well-known/oauth-authorization-server
*/
fun oAuthAuthorizationServer()
: Response<WellKnownOAuthAuthorizationServerResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package work.socialhub.kbsky.auth.api.entity.oauth

class BuildAuthorizationUrlRequest {
var requestUri: String = ""
var clientId: String = ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package work.socialhub.kbsky.auth.api.entity.oauth

import work.socialhub.kbsky.api.entity.share.MapRequest
import work.socialhub.kbsky.auth.domain.OAuthScopes

class OAuthPushedAuthorizationRequest : MapRequest {

var scope = listOf(
OAuthScopes.ATProto.value,
OAuthScopes.TransitionGeneric.value,
OAuthScopes.TransitionChatBsky.value,
)

var responseType = "code"

var clientId = ""
var redirectUri = ""

var codeChallenge: String? = null
var codeChallengeMethod = "S256"

var state: String? = null
// var nonce: String? = null

var loginHint: String? = null

override fun toMap(): Map<String, Any> =
mutableMapOf<String, Any>().also {
it.addParam("client_id", clientId)
it.addParam("redirect_uri", redirectUri)
it.addParam("response_type", responseType)
it.addParam("scope", scope.joinToString(" "))

it.addParam("code_challenge", codeChallenge)
it.addParam("code_challenge_method", codeChallengeMethod)
it.addParam("login_hint", loginHint)
it.addParam("state", state)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package work.socialhub.kbsky.auth.api.entity.oauth

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
class OAuthPushedAuthorizationResponse {
@SerialName("request_uri")
lateinit var requestUri: String

@SerialName("expires_in")
var expiresIn: Int = -1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package work.socialhub.kbsky.auth.api.entity.oauth

import work.socialhub.kbsky.api.entity.share.MapRequest

class OAuthTokenRequest : MapRequest {

var responseType = "code"
var grantType = "authorization_code"

var clientId = ""
var redirectUri = ""

var code = ""
var codeVerifier = ""

var dPoPNonce: String? = null

override fun toMap(): Map<String, Any> =
mutableMapOf<String, Any>().also {
it.addParam("response_type", responseType)
it.addParam("grant_type", grantType)

it.addParam("client_id", clientId)
it.addParam("redirect_uri", redirectUri)

it.addParam("code", code)
it.addParam("code_verifier", codeVerifier)
}
}
Loading
Loading