diff --git a/.gitignore b/.gitignore index efea3d2..06de294 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ nb-configuration.xml # Local environment .env -.env.json +env.json diff --git a/README.md b/README.md index 3578c88..a55ad78 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ mvn clean package -Pnative Running AWS SAM locally ```bash -# Make sure you have your "sam/env.json" file in place -sam local invoke --template sam/sam.native.yaml --event sam/event.json --env-vars sam/env.json +sam local invoke --template sam/sam.native.yaml --env-vars sam/env.json --event sam/events/authorize.json +``` + +```bash +sam local invoke --template sam/sam.native.yaml --env-vars sam/env.json --event sam/events/callback.json ``` diff --git a/pom.xml b/pom.xml index 1310256..89ff68e 100644 --- a/pom.xml +++ b/pom.xml @@ -27,13 +27,6 @@ pom import - - io.quarkiverse.googlecloudservices - quarkus-google-cloud-services-bom - ${quarkus.google-cloud-services.version} - pom - import - @@ -81,22 +74,25 @@ org.jboss.logging commons-logging-jboss-logging - - - io.quarkiverse.googlecloudservices - quarkus-google-cloud-secret-manager + io.quarkus + quarkus-config-yaml + + com.google.apis google-api-services-oauth2 ${google-api-services-oauth2.version} + + com.google.http-client + google-http-client + src/main/kotlin - src/test/kotlin ${quarkus.platform.group-id} @@ -208,6 +204,7 @@ + native @@ -221,8 +218,10 @@ native true quay.io/quarkus/ubi-quarkus-mandrel:22.2-java11 - 2g + 8g + -H:+RemoveSaturatedTypeFlows,\ + -H:+PrintAnalysisStatistics,\ -H:ResourceConfigurationFiles=resources-config.json,\ -H:ReflectionConfigurationFiles=reflection-config.json,\ -H:EnableURLProtocols=http\,https,\ diff --git a/sam/env.json b/sam/env.json deleted file mode 100644 index 42efa24..0000000 --- a/sam/env.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "function": { - "GOOGLE_CLIENT_SECRET": "secret" - } -} diff --git a/sam/env.json.example b/sam/env.json.example index 42efa24..c4f11dd 100644 --- a/sam/env.json.example +++ b/sam/env.json.example @@ -1,5 +1,8 @@ { - "function": { - "GOOGLE_CLIENT_SECRET": "secret" + "GoogleAuthNative": { + "LOG_LEVEL": "DEBUG", + "GOOGLE_CLIENT_ID": "", + "GOOGLE_CLIENT_SECRET": "", + "GOOGLE_CLIENT_REDIRECT_URI": "http://localhost:8080/callback" } } diff --git a/sam/event.json b/sam/events/authorize.json similarity index 51% rename from sam/event.json rename to sam/events/authorize.json index 89c26c0..6c97aff 100644 --- a/sam/event.json +++ b/sam/events/authorize.json @@ -1,4 +1,4 @@ { - "path": "/", + "path": "/authorize", "httpMethod": "GET" } diff --git a/sam/events/callback.json b/sam/events/callback.json new file mode 100644 index 0000000..304d283 --- /dev/null +++ b/sam/events/callback.json @@ -0,0 +1,4 @@ +{ + "path": "/callback?state=some-state&code=4%2F0ARtbsJq5sVmW60sVxVWpKemSD3uUpbYjkAEpDIEU7L9bXZ1xGB1UQR_UI5zaNEM-g_xJgw&scope=email+profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&authuser=0&prompt=consent", + "httpMethod": "GET" +} diff --git a/sam/sam.native.yaml b/sam/sam.native.yaml index 6975ccb..b5ba0d3 100644 --- a/sam/sam.native.yaml +++ b/sam/sam.native.yaml @@ -20,7 +20,10 @@ Resources: Environment: Variables: DISABLE_SIGNAL_HANDLERS: true - GOOGLE_CLIENT_SECRET: secret + LOG_LEVEL: null + GOOGLE_CLIENT_ID: null + GOOGLE_CLIENT_SECRET: null + GOOGLE_CLIENT_REDIRECT_URI: null Events: GetResource: Type: Api diff --git a/src/main/kotlin/com/acme/AuthUser.kt b/src/main/kotlin/com/acme/AuthUser.kt new file mode 100644 index 0000000..246eef9 --- /dev/null +++ b/src/main/kotlin/com/acme/AuthUser.kt @@ -0,0 +1,10 @@ +package com.acme + +import com.google.api.services.oauth2.model.Userinfo +import io.quarkus.runtime.annotations.RegisterForReflection + +@RegisterForReflection +data class AuthUser( + val refreshToken: String, + val userInfo: Userinfo +) diff --git a/src/main/kotlin/com/acme/GoogleOAuthClient.kt b/src/main/kotlin/com/acme/GoogleOAuthClient.kt new file mode 100644 index 0000000..b9cfe3d --- /dev/null +++ b/src/main/kotlin/com/acme/GoogleOAuthClient.kt @@ -0,0 +1,109 @@ +package com.acme + +import com.google.api.client.auth.oauth2.BearerToken +import com.google.api.client.auth.oauth2.Credential +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeRequestUrl +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest +import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets +import com.google.api.client.http.apache.v2.ApacheHttpTransport +import com.google.api.client.json.JsonFactory +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.oauth2.Oauth2 +import com.google.api.services.oauth2.Oauth2Scopes +import java.net.URL +import javax.enterprise.context.ApplicationScoped +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +@ApplicationScoped +class GoogleOAuthClient( + private val properties: GoogleOAuthProperties +) { + + private val logger: Logger = LoggerFactory.getLogger(this.javaClass) + + private val scopes = setOf( + // For basic user information + Oauth2Scopes.OPENID, + Oauth2Scopes.USERINFO_PROFILE, + Oauth2Scopes.USERINFO_EMAIL + ) + + private fun googleAuthorizationCodeFlow(): GoogleAuthorizationCodeFlow { + val jsonFactory: JsonFactory = GsonFactory.getDefaultInstance() + val details = GoogleClientSecrets.Details() + + details.clientId = properties.clientId() + details.clientSecret = properties.clientSecret() + + val clientSecrets = GoogleClientSecrets() + clientSecrets.installed = details + + // Create builder + val builder = GoogleAuthorizationCodeFlow + .Builder(ApacheHttpTransport(), jsonFactory, clientSecrets, scopes) + + return builder + .setAccessType("offline") + .setApprovalPrompt("force") + .build() + } + + fun createAuthUrl(state: String): URL { + logger.debug("Creating a new authentication URL") + val flow = googleAuthorizationCodeFlow() + + val codeRequestUrl: GoogleAuthorizationCodeRequestUrl = flow + .newAuthorizationUrl() + .setRedirectUri(properties.redirectUri()) + .setState(state) + + logger.info("Entries {}", codeRequestUrl.entries.size) + logger.info("Empty in native builds: {}", codeRequestUrl.buildRelativeUrl()) + + return codeRequestUrl.toURL() + } + + fun authenticate(authorizationCode: String): AuthUser { + logger.debug("Fetching a new refresh token") + val flow = googleAuthorizationCodeFlow() + + val codeTokenRequest: GoogleAuthorizationCodeTokenRequest = flow + .newTokenRequest(authorizationCode) + .setRedirectUri(properties.redirectUri()) + + logger.warn("Entries {}", codeTokenRequest.entries.size) + codeTokenRequest.entries.forEach { e -> + logger.info("${e.key} = ${e.value}") + } + + logger.info("Let's execute the request") + val token = codeTokenRequest.execute() + + // Let's save the refresh token in DynamoDB so that every instance will + // be able to reuse it + val httpTransport = ApacheHttpTransport() + val jsonFactory: JsonFactory = GsonFactory.getDefaultInstance() + + val httpRequestInitializer = Credential.Builder(BearerToken.authorizationHeaderAccessMethod()).build() + httpRequestInitializer.accessToken = token.accessToken + logger.info("httpRequestInitializer.accessToken {}", httpRequestInitializer.accessToken) + + val oauth2: Oauth2 = Oauth2 + .Builder(httpTransport, jsonFactory, httpRequestInitializer) + .setApplicationName(properties.applicationName()) + .build() + + val get = oauth2 + .userinfo() + .get() + + val userInfo = get.execute() + + return AuthUser( + refreshToken = token.refreshToken, + userInfo = userInfo + ) + } +} diff --git a/src/main/kotlin/com/acme/GoogleOAuthProperties.kt b/src/main/kotlin/com/acme/GoogleOAuthProperties.kt new file mode 100644 index 0000000..5093b7f --- /dev/null +++ b/src/main/kotlin/com/acme/GoogleOAuthProperties.kt @@ -0,0 +1,17 @@ +package com.acme + +import io.quarkus.runtime.annotations.RegisterForReflection +import io.smallrye.config.ConfigMapping + +@RegisterForReflection +@ConfigMapping(prefix = GoogleOAuthProperties.PREFIX) +interface GoogleOAuthProperties { + fun applicationName(): String + fun clientId(): String + fun clientSecret(): String + fun redirectUri(): String + + companion object { + const val PREFIX = "pws-google" + } +} diff --git a/src/main/kotlin/com/acme/GreetingResource.kt b/src/main/kotlin/com/acme/GreetingResource.kt deleted file mode 100644 index 2ed61d7..0000000 --- a/src/main/kotlin/com/acme/GreetingResource.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.acme - -import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow -import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets -import com.google.api.client.http.apache.v2.ApacheHttpTransport -import com.google.api.client.json.JsonFactory -import com.google.api.client.json.gson.GsonFactory -import com.google.api.services.oauth2.Oauth2Scopes -import javax.ws.rs.GET -import javax.ws.rs.Path -import javax.ws.rs.Produces -import javax.ws.rs.core.MediaType -import org.slf4j.Logger -import org.slf4j.LoggerFactory - - -@Path("/") -class GreetingResource { - - private val logger: Logger = LoggerFactory.getLogger(this.javaClass) - - private val scopes = setOf( - // For basic user information - Oauth2Scopes.OPENID, - Oauth2Scopes.USERINFO_PROFILE, - Oauth2Scopes.USERINFO_EMAIL - ) - - @GET - @Produces(MediaType.TEXT_PLAIN) - fun get(): String { - logger.info("Build auth url") - val flow = googleAuthorizationCodeFlow() - - val codeRequestUrl = flow - .newAuthorizationUrl() - .setRedirectUri("http://localhost:3000") - .setState("some-state") - - logger.debug("Why is this empty in native? {}", codeRequestUrl.entries.size) - - return codeRequestUrl.toURL().toString() - } - - private fun googleAuthorizationCodeFlow(): GoogleAuthorizationCodeFlow { - val jsonFactory: JsonFactory = GsonFactory.getDefaultInstance() - val details = GoogleClientSecrets.Details() - - details.clientId = "some-id" - details.clientSecret = "some-secret" - - val clientSecrets = GoogleClientSecrets() - clientSecrets.installed = details - - val builder = GoogleAuthorizationCodeFlow - .Builder(ApacheHttpTransport(), jsonFactory, clientSecrets, scopes) - - return builder - .setAccessType("offline") - .setApprovalPrompt("force") - .build() - } -} diff --git a/src/main/kotlin/com/acme/ReflectionConfig.kt b/src/main/kotlin/com/acme/ReflectionConfig.kt new file mode 100644 index 0000000..b2f084a --- /dev/null +++ b/src/main/kotlin/com/acme/ReflectionConfig.kt @@ -0,0 +1,54 @@ +package com.acme + +import io.quarkus.runtime.annotations.RegisterForReflection + +@Suppress("unused") +@RegisterForReflection( + targets = [ + // google-http-client + com.google.api.client.http.HttpHeaders::class, + com.google.api.client.json.rpc2.JsonRpcRequest::class, + com.google.api.client.json.webtoken.JsonWebSignature::class, + com.google.api.client.json.webtoken.JsonWebToken.Header::class, + // google-api-client + com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeRequestUrl::class, + com.google.api.client.googleapis.auth.oauth2.GoogleBrowserClientRequestUrl::class, + com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets::class, + com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets.Details::class, + com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload::class, + com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse::class, + com.google.api.client.googleapis.json.GoogleJsonError.ErrorInfo::class, + com.google.api.client.googleapis.json.GoogleJsonError.Details::class, + com.google.api.client.googleapis.json.GoogleJsonError.ParameterViolations::class, + com.google.api.client.googleapis.json.GoogleJsonError::class, + com.google.api.client.googleapis.json.GoogleJsonErrorContainer::class, + com.google.api.client.googleapis.mtls.ContextAwareMetadataJson::class, + // google-api-services-oauth2-v2 + com.google.api.services.oauth2.model.Userinfo::class, + com.google.api.services.oauth2.model.Tokeninfo::class, + com.google.api.services.oauth2.Oauth2.Tokeninfo::class, + com.google.api.services.oauth2.Oauth2.Userinfo.Get::class, + com.google.api.services.oauth2.Oauth2.Userinfo.V2.Me::class, + com.google.api.services.oauth2.Oauth2.Userinfo.V2.Me.Get::class, + // google-oauth-client + com.google.api.client.auth.oauth.OAuthAuthorizeTemporaryTokenUrl::class, + com.google.api.client.auth.oauth.OAuthCallbackUrl::class, + com.google.api.client.auth.oauth.OAuthCredentialsResponse::class, + com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl::class, + com.google.api.client.auth.oauth2.AuthorizationCodeResponseUrl::class, + com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest::class, + com.google.api.client.auth.oauth2.AuthorizationRequestUrl::class, + com.google.api.client.auth.oauth2.PasswordTokenRequest::class, + com.google.api.client.auth.oauth2.RefreshTokenRequest::class, + com.google.api.client.auth.oauth2.TokenErrorResponse::class, + com.google.api.client.auth.oauth2.TokenRequest::class, + com.google.api.client.auth.oauth2.TokenResponse::class, + com.google.api.client.auth.openidconnect.IdToken.Payload::class, + com.google.api.client.auth.openidconnect.IdTokenResponse::class + // @TODO: This must be fixed first + // https://github.com/googleapis/google-oauth-java-client/issues/947 + // com.google.api.client.auth.openidconnect.IdTokenVerifier.PublicKeyLoader.JsonWebKeySet::class + // com.google.api.client.auth.openidconnect.IdTokenVerifier.PublicKeyLoader.JsonWebKey::class + ] +) +class ReflectionConfig diff --git a/src/main/kotlin/com/acme/resources/AuthorizeResource.kt b/src/main/kotlin/com/acme/resources/AuthorizeResource.kt new file mode 100644 index 0000000..cbccf5d --- /dev/null +++ b/src/main/kotlin/com/acme/resources/AuthorizeResource.kt @@ -0,0 +1,23 @@ +package com.acme.resources + +import com.acme.GoogleOAuthClient +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import org.slf4j.Logger +import org.slf4j.LoggerFactory + + +@Path("/authorize") +class AuthorizeResource(private val googleOAuthClient: GoogleOAuthClient) { + + private val logger: Logger = LoggerFactory.getLogger(this.javaClass) + + @GET + @Produces(MediaType.TEXT_PLAIN) + fun handle(): String { + logger.info("Build auth url") + return googleOAuthClient.createAuthUrl("some-state").toString() + } +} diff --git a/src/main/kotlin/com/acme/resources/CallbackResource.kt b/src/main/kotlin/com/acme/resources/CallbackResource.kt new file mode 100644 index 0000000..706561d --- /dev/null +++ b/src/main/kotlin/com/acme/resources/CallbackResource.kt @@ -0,0 +1,31 @@ +package com.acme.resources + +import com.acme.GoogleOAuthClient +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType +import org.slf4j.Logger +import org.slf4j.LoggerFactory + + +@Path("/callback") +class CallbackResource( + private val googleOAuthClient: GoogleOAuthClient +) { + + private val logger: Logger = LoggerFactory.getLogger(this.javaClass) + + @GET + @Produces(MediaType.TEXT_PLAIN) + fun handle( + @QueryParam("code") code: String, + @QueryParam("state") state: String + ): String { + logger.info("Get refresh token") + val (refreshToken) = googleOAuthClient.authenticate(code) + + return refreshToken + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..c2a736b --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,9 @@ +pws-google: + application-name: "Some app" + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_CLIENT_REDIRECT_URI} + +quarkus: + log: + level: ${LOG_LEVEL} diff --git a/src/main/resources/reflection-config.json b/src/main/resources/reflection-config.json index ed64df0..fe51488 100644 --- a/src/main/resources/reflection-config.json +++ b/src/main/resources/reflection-config.json @@ -1,9 +1 @@ -[ - { - "name": "com.google.api.client.http.GenericUrl", - "allPublicMethods": true, - "allDeclaredFields": true, - "allDeclaredMethods": true, - "allDeclaredConstructors": true - } -] +[] diff --git a/src/main/resources/reflection-config.json.example b/src/main/resources/reflection-config.json.example new file mode 100644 index 0000000..8ac2f58 --- /dev/null +++ b/src/main/resources/reflection-config.json.example @@ -0,0 +1,58 @@ +[ + { + "name": "com.google.api.client.http.GenericUrl", + "allPublicMethods": true, + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.google.api.client.auth.oauth2.TokenErrorResponse", + "allPublicMethods": true, + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest", + "allPublicMethods": true, + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest", + "allPublicMethods": true, + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": ",com.google.api.client.auth.oauth2.TokenRequest", + "allPublicMethods": true, + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.google.api.client.util.GenericData", + "allPublicMethods": true, + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.google.api.client.util.GenericData.EntrySet", + "allPublicMethods": true, + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "java.util.AbstractMap", + "allPublicMethods": true, + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + } +] diff --git a/src/test/kotlin/com/acme/GreetingResourceIT.kt b/src/test/kotlin/com/acme/GreetingResourceIT.kt deleted file mode 100644 index e393621..0000000 --- a/src/test/kotlin/com/acme/GreetingResourceIT.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.acme - -import io.quarkus.test.junit.QuarkusIntegrationTest - -@QuarkusIntegrationTest -class GreetingResourceIT : GreetingResourceTest() diff --git a/src/test/kotlin/com/acme/GreetingResourceTest.kt b/src/test/kotlin/com/acme/GreetingResourceTest.kt deleted file mode 100644 index 68bd32b..0000000 --- a/src/test/kotlin/com/acme/GreetingResourceTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.acme - -import io.quarkus.test.junit.QuarkusTest -import io.restassured.RestAssured.given -import org.hamcrest.CoreMatchers.`is` -import org.junit.jupiter.api.Test - -@QuarkusTest -class GreetingResourceTest { - - @Test - fun testHelloEndpoint() { - given() - .`when`().get("/") - .then() - .statusCode(200) - .body(`is`("https://accounts.google.com/o/oauth2/auth?access_type=offline&approval_prompt=force&client_id=some-id&redirect_uri=http://localhost:3000&response_type=code&scope=openid%20https://www.googleapis.com/auth/userinfo.profile%20https://www.googleapis.com/auth/userinfo.email&state=some-state")) - } -}