Skip to content

Commit 2313fe6

Browse files
rmmeanstsuyoshizawa
authored andcommitted
Add support for PKCE (RFC 7636) (#130)
* Add support for PKCE (RFC 7636) * Enhanced the AuthInfo type to also contain `codeChallenge` and `codeChallengeMethod` that would be passed to the Auth server during an authorization request if the request was using PKCE. These fields default to None, which should allow backwards compatibility with all existing implementations. The auth server that uses this library would need to decide if they were supporting PKCE and capture these values from an authorization request and set these values in the `AuthInfo` type * Adds support for PKCE by enhancing the AuthorizationCode grant handler. The grant handler now checks the AuthInfo for a contained PKCE `codeChallenge`. If one is set, the auth code grant handler validates the PKCE challenge as part of the standard validations * switch travis to only use openjdk for both 8 and 11 * Added toString overrides on case objects for CodeChallengeMethod useful when serializing the type, apply method can still be used for deserialization / taking user input from authorization request launch * Update readme.
1 parent 21e3c86 commit 2313fe6

File tree

7 files changed

+196
-8
lines changed

7 files changed

+196
-8
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ scala:
44
- 2.11.12
55
- 2.13.0
66
jdk:
7-
- oraclejdk8
7+
- openjdk8
88
- openjdk11
99
notifications:
1010
webhooks:

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The idea of this library originally comes from [oauth2-server](https://github.co
1010

1111
This library supports all grant types.
1212

13-
- Authorization Code Grant
13+
- Authorization Code Grant (PKCE Authorization Code Grants are supported)
1414
- Resource Owner Password Credentials Grant
1515
- Client Credentials Grant
1616
- Implicit Grant
@@ -86,7 +86,9 @@ case class AuthInfo[User](
8686
user: User,
8787
clientId: Option[String],
8888
scope: Option[String],
89-
redirectUri: Option[String]
89+
redirectUri: Option[String],
90+
codeChallenge: Option[String] = None,
91+
codeChallengeMethod: Option[CodeChallengeMethod] = None
9092
)
9193
```
9294

@@ -100,3 +102,9 @@ case class AuthInfo[User](
100102
- inform the client of the scope of the access token issued
101103
- redirectUri
102104
- This value must be enabled on authorization code grant
105+
- codeChallenge:
106+
- This value is OPTIONAL. Only set this value if doing a PKCE authorization request. When set, PKCE rules apply on the AuthorizationCode Grant Handler
107+
- This value is from a PKCE authorization request. This is the challenge supplied during the auth request if given.
108+
- codeChallengeMethod:
109+
- This value is OPTIONAL and used only by PKCE when a codeChallenge value is also set.
110+
- This value is from a PKCE authorization request. This is the method used to transform the code verifier. Must be either Plain or S256. If not specified and codeChallenge is provided then Plain is assumed (per RFC7636)

src/main/scala/scalaoauth2/provider/AuthorizationRequest.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ case class AuthorizationCodeRequest(request: AuthorizationRequest) extends Autho
8888
* @return redirect_uri
8989
*/
9090
def redirectUri: Option[String] = param("redirect_uri")
91+
92+
/**
93+
* Returns code_verifier
94+
*
95+
* @return
96+
*/
97+
def codeVerifier: Option[String] = param("code_verifier")
9198
}
9299

93100
case class ImplicitRequest(request: AuthorizationRequest) extends AuthorizationRequest(request.headers, request.params)

src/main/scala/scalaoauth2/provider/DataHandler.scala

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package scalaoauth2.provider
22

33
import java.util.Date
44

5+
import scala.util.{ Failure, Success, Try }
6+
57
/**
68
* Provide accessing to data storage for using OAuth 2.0.
79
*/
@@ -29,12 +31,32 @@ case class AccessToken(token: String, refreshToken: Option[String], scope: Optio
2931
}
3032
}
3133

34+
sealed trait CodeChallengeMethod
35+
case object Plain extends CodeChallengeMethod {
36+
override def toString: String = "plain"
37+
}
38+
case object S256 extends CodeChallengeMethod {
39+
override def toString: String = "S256"
40+
}
41+
42+
object CodeChallengeMethod {
43+
def apply(value: String): Try[CodeChallengeMethod] = {
44+
value match {
45+
case "S256" => Success(S256)
46+
case "plain" => Success(Plain)
47+
case _ => Failure(new InvalidRequest("transform algorithm not supported"))
48+
}
49+
}
50+
}
51+
3252
/**
3353
* Authorized information
3454
*
3555
* @param user Authorized user which is registered on system.
3656
* @param clientId Using client id which is registered on system.
3757
* @param scope Inform the client of the scope of the access token issued.
3858
* @param redirectUri This value is used by Authorization Code Grant.
59+
* @param codeChallenge This value is used by Authorization Code Grant for PKCE support.
60+
* @param codeChallengeMethod This value is used by Authorization Code Grant for PKCE support.
3961
*/
40-
case class AuthInfo[+U](user: U, clientId: Option[String], scope: Option[String], redirectUri: Option[String])
62+
case class AuthInfo[+U](user: U, clientId: Option[String], scope: Option[String], redirectUri: Option[String], codeChallenge: Option[String] = None, codeChallengeMethod: Option[CodeChallengeMethod] = None)

src/main/scala/scalaoauth2/provider/GrantHandler.scala

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package scalaoauth2.provider
22

3+
import java.util.Base64
4+
import java.security.MessageDigest
5+
36
import scala.concurrent.{ ExecutionContext, Future }
47

58
case class GrantHandlerResult[U](
@@ -78,7 +81,7 @@ class Password extends GrantHandler {
7881
handler.findUser(maybeValidatedClientCred, passwordRequest).flatMap { maybeUser =>
7982
val user = maybeUser.getOrElse(throw new InvalidGrant("username or password is incorrect"))
8083
val scope = passwordRequest.scope
81-
val authInfo = AuthInfo(user, maybeValidatedClientCred.map(_.clientId), scope, None)
84+
val authInfo = AuthInfo(user, maybeValidatedClientCred.map(_.clientId), scope, None, None, None)
8285

8386
issueAccessToken(handler, authInfo)
8487
}
@@ -95,7 +98,7 @@ class ClientCredentials extends GrantHandler {
9598

9699
handler.findUser(maybeValidatedClientCred, clientCredentialsRequest).flatMap { optionalUser =>
97100
val user = optionalUser.getOrElse(throw new InvalidGrant("client_id or client_secret or scope is incorrect"))
98-
val authInfo = AuthInfo(user, Some(clientId), scope, None)
101+
val authInfo = AuthInfo(user, Some(clientId), scope, None, None, None)
99102

100103
issueAccessToken(handler, authInfo)
101104
}
@@ -121,10 +124,27 @@ class AuthorizationCode extends GrantHandler {
121124
throw new RedirectUriMismatch
122125
}
123126

127+
authInfo.codeChallenge.foreach { codeChallenge =>
128+
val codeVerifier = authorizationCodeRequest.codeVerifier.getOrElse(throw new InvalidGrant("PKCE validation failed, no verifier included in request"))
129+
130+
val isValid: Boolean = authInfo.codeChallengeMethod.getOrElse(Plain) match {
131+
case Plain => codeVerifier == codeChallenge
132+
case S256 =>
133+
val codeVerifierBytes = codeVerifier.getBytes("ASCII")
134+
val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifierBytes)
135+
val computedChallenge = Base64.getUrlEncoder.withoutPadding().encodeToString(digest)
136+
137+
computedChallenge == codeChallenge
138+
}
139+
140+
if (!isValid)
141+
throw new InvalidGrant("PKCE validation failed, values not equal.")
142+
}
143+
124144
val f = issueAccessToken(handler, authInfo)
125145
for {
126146
accessToken <- f
127-
deleteResult <- handler.deleteAuthCode(code)
147+
_ <- handler.deleteAuthCode(code)
128148
} yield accessToken
129149
}
130150
}
@@ -140,7 +160,7 @@ class Implicit extends GrantHandler {
140160
handler.findUser(maybeValidatedClientCred, implicitRequest).flatMap { maybeUser =>
141161
val user = maybeUser.getOrElse(throw new InvalidGrant("user cannot be authenticated"))
142162
val scope = implicitRequest.scope
143-
val authInfo = AuthInfo(user, Some(clientId), scope, None)
163+
val authInfo = AuthInfo(user, Some(clientId), scope, None, None, None)
144164

145165
issueAccessToken(handler, authInfo)
146166
}

src/test/scala/scalaoauth2/provider/AuthorizationCodeSpec.scala

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,103 @@ class AuthorizationCodeSpec extends FlatSpec with ScalaFutures with OptionValues
6060
}
6161
}
6262

63+
it should "handle a PKCE plain (implicit) request" in {
64+
val authorizationCode = new AuthorizationCode()
65+
val request = new AuthorizationRequest(Map(), Map("client_id" -> Seq("clientId1"), "code" -> Seq("code1"), "redirect_uri" -> Seq("http://example.com/"), "code_verifier" -> Seq("4g94A5mKbcP1zv313x6JmVQjDJ1FiwVFBnLepwk1BLk")))
66+
val clientCred = request.parseClientCredential.fold[Option[ClientCredential]](None)(_.fold(_ => None, c => Some(c)))
67+
val f = authorizationCode.handleRequest(clientCred, request, new MockDataHandler() {
68+
69+
override def findAuthInfoByCode(code: String): Future[Option[AuthInfo[MockUser]]] = Future.successful(Some(
70+
AuthInfo(user = MockUser(10000, "username"), clientId = Some("clientId1"), scope = Some("all"), redirectUri = None, codeChallenge = Some("4g94A5mKbcP1zv313x6JmVQjDJ1FiwVFBnLepwk1BLk"))))
71+
72+
override def createAccessToken(authInfo: AuthInfo[User]): Future[AccessToken] = Future.successful(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new java.util.Date()))
73+
})
74+
75+
whenReady(f) { result =>
76+
result.tokenType shouldBe "Bearer"
77+
result.accessToken shouldBe "token1"
78+
result.expiresIn.value should (be <= 3600L and be > 2595L)
79+
result.refreshToken shouldBe Some("refreshToken1")
80+
result.scope shouldBe Some("all")
81+
}
82+
}
83+
84+
it should "handle a PKCE plain (explicit) request" in {
85+
val authorizationCode = new AuthorizationCode()
86+
val request = new AuthorizationRequest(Map(), Map("client_id" -> Seq("clientId1"), "code" -> Seq("code1"), "redirect_uri" -> Seq("http://example.com/"), "code_verifier" -> Seq("4g94A5mKbcP1zv313x6JmVQjDJ1FiwVFBnLepwk1BLk")))
87+
val clientCred = request.parseClientCredential.fold[Option[ClientCredential]](None)(_.fold(_ => None, c => Some(c)))
88+
val f = authorizationCode.handleRequest(clientCred, request, new MockDataHandler() {
89+
90+
override def findAuthInfoByCode(code: String): Future[Option[AuthInfo[MockUser]]] = Future.successful(Some(
91+
AuthInfo(user = MockUser(10000, "username"), clientId = Some("clientId1"), scope = Some("all"), redirectUri = None, codeChallenge = Some("4g94A5mKbcP1zv313x6JmVQjDJ1FiwVFBnLepwk1BLk"), codeChallengeMethod = Some(Plain))))
92+
93+
override def createAccessToken(authInfo: AuthInfo[User]): Future[AccessToken] = Future.successful(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new java.util.Date()))
94+
})
95+
96+
whenReady(f) { result =>
97+
result.tokenType shouldBe "Bearer"
98+
result.accessToken shouldBe "token1"
99+
result.expiresIn.value should (be <= 3600L and be > 2595L)
100+
result.refreshToken shouldBe Some("refreshToken1")
101+
result.scope shouldBe Some("all")
102+
}
103+
}
104+
105+
it should "handle a PKCE S256 request" in {
106+
val authorizationCode = new AuthorizationCode()
107+
val request = new AuthorizationRequest(Map(), Map("client_id" -> Seq("clientId1"), "code" -> Seq("code1"), "redirect_uri" -> Seq("http://example.com/"), "code_verifier" -> Seq("4g94A5mKbcP1zv313x6JmVQjDJ1FiwVFBnLepwk1BLk")))
108+
val clientCred = request.parseClientCredential.fold[Option[ClientCredential]](None)(_.fold(_ => None, c => Some(c)))
109+
val f = authorizationCode.handleRequest(clientCred, request, new MockDataHandler() {
110+
111+
override def findAuthInfoByCode(code: String): Future[Option[AuthInfo[MockUser]]] = Future.successful(Some(
112+
AuthInfo(user = MockUser(10000, "username"), clientId = Some("clientId1"), scope = Some("all"), redirectUri = None, codeChallenge = Some("aLE640XFc4YAPoVB3KCUhcjoMRDQhyILWy9k9qpiBfo"), codeChallengeMethod = Some(S256))))
113+
114+
override def createAccessToken(authInfo: AuthInfo[User]): Future[AccessToken] = Future.successful(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new java.util.Date()))
115+
})
116+
117+
whenReady(f) { result =>
118+
result.tokenType shouldBe "Bearer"
119+
result.accessToken shouldBe "token1"
120+
result.expiresIn.value should (be <= 3600L and be > 2595L)
121+
result.refreshToken shouldBe Some("refreshToken1")
122+
result.scope shouldBe Some("all")
123+
}
124+
}
125+
126+
it should "return a InvalidGrant if PKCE validation fails for equality" in {
127+
val authorizationCode = new AuthorizationCode()
128+
val request = new AuthorizationRequest(Map(), Map("client_id" -> Seq("clientId1"), "code" -> Seq("code1"), "redirect_uri" -> Seq("http://example.com/"), "code_verifier" -> Seq("iB3OM4lYP6k03yT_sMGr1o_Mf-lOX84mrM7jRkm21Ak")))
129+
val clientCred = request.parseClientCredential.fold[Option[ClientCredential]](None)(_.fold(_ => None, c => Some(c)))
130+
val f = authorizationCode.handleRequest(clientCred, request, new MockDataHandler() {
131+
132+
override def findAuthInfoByCode(code: String): Future[Option[AuthInfo[MockUser]]] = Future.successful(Some(
133+
AuthInfo(user = MockUser(10000, "username"), clientId = Some("clientId1"), scope = Some("all"), redirectUri = None, codeChallenge = Some("aLE640XFc4YAPoVB3KCUhcjoMRDQhyILWy9k9qpiBfo"), codeChallengeMethod = Some(S256))))
134+
135+
override def createAccessToken(authInfo: AuthInfo[User]): Future[AccessToken] = Future.successful(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new java.util.Date()))
136+
})
137+
138+
whenReady(f.failed) { e =>
139+
e shouldBe a[InvalidGrant]
140+
}
141+
}
142+
143+
it should "return a InvalidGrant if code_verifier is not included when AuthInfo contains a codeChallenge" in {
144+
val authorizationCode = new AuthorizationCode()
145+
val request = new AuthorizationRequest(Map(), Map("client_id" -> Seq("clientId1"), "code" -> Seq("code1"), "redirect_uri" -> Seq("http://example.com/")))
146+
val clientCred = request.parseClientCredential.fold[Option[ClientCredential]](None)(_.fold(_ => None, c => Some(c)))
147+
val f = authorizationCode.handleRequest(clientCred, request, new MockDataHandler() {
148+
149+
override def findAuthInfoByCode(code: String): Future[Option[AuthInfo[MockUser]]] = Future.successful(Some(
150+
AuthInfo(user = MockUser(10000, "username"), clientId = Some("clientId1"), scope = Some("all"), redirectUri = None, codeChallenge = Some("aLE640XFc4YAPoVB3KCUhcjoMRDQhyILWy9k9qpiBfo"))))
151+
152+
override def createAccessToken(authInfo: AuthInfo[User]): Future[AccessToken] = Future.successful(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new java.util.Date()))
153+
})
154+
155+
whenReady(f.failed) { e =>
156+
e shouldBe a[InvalidGrant]
157+
}
158+
}
159+
63160
it should "return a Failure Future" in {
64161
val authorizationCode = new AuthorizationCode()
65162
val request = new AuthorizationRequest(Map(), Map("client_id" -> Seq("clientId1"), "client_secret" -> Seq("clientSecret1"), "code" -> Seq("code1"), "redirect_uri" -> Seq("http://example.com/")))
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package scalaoauth2.provider
2+
3+
import org.scalatest.Matchers._
4+
import org.scalatest._
5+
6+
import scala.util.Success
7+
8+
class DataHandlerSpec extends FlatSpec with TryValues {
9+
10+
it should "parse a PKCE code challenge method of plain" in {
11+
CodeChallengeMethod("plain") should be(Success(Plain))
12+
}
13+
14+
it should "parse a PKCE code challenge method of S256" in {
15+
CodeChallengeMethod("S256") should be(Success(S256))
16+
}
17+
18+
it should "turn a PKCE code challenge method type of S256 back to a string value of S256" in {
19+
val method: CodeChallengeMethod = S256
20+
method.toString should be("S256")
21+
}
22+
23+
it should "turn a PKCE code challenge method type of plain back to a string value of plain" in {
24+
val method: CodeChallengeMethod = Plain
25+
method.toString should be("plain")
26+
}
27+
28+
it should "return a failure if non valid PKCE code challenge method" in {
29+
val attempt = CodeChallengeMethod("made-up")
30+
attempt.isFailure shouldBe true
31+
attempt.failure.exception.isInstanceOf[InvalidRequest]
32+
attempt.failure.exception.getMessage should be("transform algorithm not supported")
33+
}
34+
}

0 commit comments

Comments
 (0)