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

[DEV-25] Add authentication #1

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
bin/
obj/
dll/
dll/
src/Behide.OnlineServices/Api/Generated/*
!src/Behide.OnlineServices/Api/Generated/Types.fs
.env
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
<ProjectReference Include="../Behide.OnlineServices/Behide.OnlineServices.fsproj" />

<Compile Include="Common.fs" />

<Compile Include="Tests/Api/Auth.fs" />
<Compile Include="Tests/Signaling.fs" />

<Compile Include="Program.fs" />
</ItemGroup>

Expand Down
47 changes: 44 additions & 3 deletions src/Behide.OnlineServices.Tests/Common.fs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
module Behide.OnlineServices.Tests.Common

open Falco
open FsToolkit.ErrorHandling
open Expecto.Tests

open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.TestHost
open Microsoft.Extensions.DependencyInjection
Expand Down Expand Up @@ -28,13 +32,14 @@ let createTestServer () =
.Configure(fun app ->
app
|> Program.appBuilder
|> fun builder -> builder.UseFalco(Program.appEndpoints)
|> ignore
)

new TestServer(hostBuilder),
offerStore :> Store.IStore<_, _>,
roomStore :> Store.IStore<_, _>,
playerConnsStore :> Store.IStore<_, _>
{| OfferStore = offerStore :> Store.IStore<_, _>
RoomStore = roomStore :> Store.IStore<_, _>
PlayerConnsStore = playerConnsStore :> Store.IStore<_, _> |}

let fakeSdpDescription: SdpDescription =
{ ``type`` = "fake type"
Expand All @@ -56,3 +61,39 @@ type TimedTaskCompletionSource<'A>(timeout: int) =
member _.Task = tcs.Task
member _.SetResult(result) = tcs.SetResult(result) |> ignore
member _.SetException(ex: exn) = tcs.SetException(ex) |> ignore

module Serialization =
open Thoth.Json.Net

let private decoder<'T> = Decode.Auto.generateDecoderCached<'T>()

let decode<'T> = Decode.fromString decoder<'T>
let wantDecodable<'T> message =
decode<'T> >> Result.defaultWith (failtestf "Failed to decode: %s: %s" message)

let isDecodable<'T> message = wantDecodable<'T> message >> ignore

let decodeHttpResponse<'T> (response: System.Net.Http.HttpResponseMessage) =
response.Content.ReadAsStringAsync()
|> Task.map (Decode.fromString decoder<'T>)
|> TaskResult.defaultWith (fun _ -> failtest "Failed to decode response")

module User =
open Behide.OnlineServices.Api
open Behide.OnlineServices.Repository

let createUser () =
{ Id = UserId.create()
Name = sprintf "fake name: %i" (System.DateTimeOffset.Now.ToUnixTimeMilliseconds())
AuthConnection = Auth.ProviderConnection.Google "fake google id"
RefreshTokenHashes = Array.empty }

let putInDatabase user =
task {
do! user |> Database.Users.insert
return user
}

let putRefreshTokenHashInDb (user: User) refreshToken =
Database.Users.addRefreshTokenHashToUser user.Id refreshToken
|> TaskResult.defaultWith (fun _ -> failtest "Failed to add refresh token to user")
221 changes: 221 additions & 0 deletions src/Behide.OnlineServices.Tests/Tests/Api/Auth.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
module Behide.OnlineServices.Tests.Api.Auth

open System
open System.Net
open System.Net.Http
open System.IdentityModel.Tokens.Jwt
open System.Security.Claims
open FsToolkit.ErrorHandling

open Expecto
open Expecto.Flip

open Behide.OnlineServices
open Behide.OnlineServices.Tests.Common
open System.Net.Http.Json
open Microsoft.IdentityModel.Tokens


let createRefreshTokensReq (client: HttpClient) (accessToken: string) (refreshToken: string) =
new HttpRequestMessage(
RequestUri = Uri(client.BaseAddress, "/auth/refresh-token"),
Method = HttpMethod.Post,
Content =
JsonContent.Create
{| accessToken = accessToken
refreshToken = refreshToken |}
)

let verifyAccessToken (user: User) (token: string) =
let validationParameters = Common.Config.Auth.JWT.validationParameters
let handler = new JwtSecurityTokenHandler()
let mutable validatedToken: SecurityToken = new JwtSecurityToken()

let claimPrincipal =
try
handler.ValidateToken(token, validationParameters, &validatedToken)
with error ->
failtestf "Access token validation failed: %s" error.Message

let getClaim claimType =
claimPrincipal.FindFirstValue claimType
|> Option.ofNull
|> function
| Some value -> value
| None -> failtestf "JWT claims should contain %s" claimType

// Test claims
let audience = getClaim "aud"
let issuer = getClaim "iss"
let nameIdentifier = getClaim ClaimTypes.NameIdentifier
let userNameClaim = getClaim ClaimTypes.Name

Expect.equal "JWT issuer should not be that" Common.Config.Auth.JWT.issuer issuer
Expect.equal "JWT audience should not be that" Common.Config.Auth.JWT.audience audience
Expect.equal "JWT name identifier should not be that" (user.Id |> UserId.rawString) nameIdentifier
Expect.equal "JWT name should not be that" user.Name userNameClaim


let verifyRefreshToken
(user: User)
(refreshToken: Api.Auth.RefreshToken)
(refreshTokenHash: Api.Auth.RefreshTokenHash)
=
// Test refresh token hash
Api.Auth.Jwt.verifyRefreshTokenHash
user.Id
refreshTokenHash
refreshToken.Token
|> Expect.isTrue "Refresh token should be valid"

[<Tests>]
let tests = testList "Auth" [
let testServer, _ = createTestServer()
let client = testServer.CreateClient()

testList "JWT" [
testTask "Generate tokens" {
let user = User.createUser()
let tokens = Api.Auth.Jwt.generateTokens user.Id user.Name

verifyAccessToken user tokens.AccessToken
verifyRefreshToken
user
tokens.RefreshToken
tokens.RefreshTokenHash
}
]

testList "Refresh tokens" [
testTask "Authorized user should be able to refresh his tokens" {
let user = User.createUser()
do! user |> User.putInDatabase

let tokens = Api.Auth.Jwt.generateTokens user.Id user.Name
do! tokens.RefreshTokenHash |> User.putRefreshTokenHashInDb user

// Refresh
let! (response: Api.Types.TokenPairDTO) =
createRefreshTokensReq
client
tokens.AccessToken
tokens.RefreshToken.Token
|> client.SendAsync
|> Task.bind Serialization.decodeHttpResponse

// Check if new tokens are valid
verifyAccessToken user response.accessToken

// Check if new refresh token is valid
Expect.isTrue
"Refresh token should not be expired"
(DateTimeOffset.UtcNow < response.refreshTokenExpiration)
}

testTask "Unauthorized user should not be able to refresh tokens" {
let! (response: HttpResponseMessage) =
createRefreshTokensReq
client
"fake access token"
"fake refresh token"
|> client.SendAsync

Expect.equal "Status code should be Unauthorized" HttpStatusCode.Unauthorized response.StatusCode
}

testTask "Invalid body should return BadRequest" {
let! (response: HttpResponseMessage) =
new HttpRequestMessage(
RequestUri = Uri(client.BaseAddress, "/auth/refresh-token"),
Method = HttpMethod.Post,
Content = JsonContent.Create {| aaa = "aaa" |}
)
|> client.SendAsync

Expect.equal "Status code should be BadRequest" HttpStatusCode.BadRequest response.StatusCode
}

testTask "Expired refresh token should return Unauthorized" {
let user = User.createUser()
do! user |> User.putInDatabase

let tokens = Api.Auth.Jwt.generateTokens user.Id user.Name
do! { tokens.RefreshTokenHash with
Expiration = DateTimeOffset.MinValue }
|> User.putRefreshTokenHashInDb user

// Refresh
let! (response: HttpResponseMessage) =
createRefreshTokensReq
client
tokens.AccessToken
tokens.RefreshToken.Token
|> client.SendAsync

Expect.equal "Status code should be Unauthorized" HttpStatusCode.Unauthorized response.StatusCode
}

testTask "Refresh with wrong refresh token should return Unauthorized" {
let user = User.createUser()
do! user |> User.putInDatabase

let tokens = Api.Auth.Jwt.generateTokens user.Id user.Name
do! tokens.RefreshTokenHash |> User.putRefreshTokenHashInDb user

// Refresh
let! (response: HttpResponseMessage) =
createRefreshTokensReq
client
tokens.AccessToken
"fake refresh token"
|> client.SendAsync

Expect.equal "Status code should be Unauthorized" HttpStatusCode.Unauthorized response.StatusCode
}

testTask "Refresh with wrong access token should return Unauthorized" {
let user = User.createUser()
do! user |> User.putInDatabase

let tokens = Api.Auth.Jwt.generateTokens user.Id user.Name
do! tokens.RefreshTokenHash |> User.putRefreshTokenHashInDb user

// Refresh
let! (response: HttpResponseMessage) =
createRefreshTokensReq
client
"fake access token"
tokens.RefreshToken.Token
|> client.SendAsync

Expect.equal "Status code should be Unauthorized" HttpStatusCode.Unauthorized response.StatusCode
}

testTask "Re-refresh with old refresh token should return Unauthorized" {
let user = User.createUser()
do! user |> User.putInDatabase

let tokens = Api.Auth.Jwt.generateTokens user.Id user.Name
do! tokens.RefreshTokenHash |> User.putRefreshTokenHashInDb user

// Refresh
let! (response: Api.Types.TokenPairDTO) =
createRefreshTokensReq
client
tokens.AccessToken
tokens.RefreshToken.Token
|> client.SendAsync
|> Task.bind Serialization.decodeHttpResponse

// Refresh again
let! (response: HttpResponseMessage) =
createRefreshTokensReq
client
response.accessToken
tokens.RefreshToken.Token
|> client.SendAsync

Expect.equal "Status code should be Unauthorized" HttpStatusCode.Unauthorized response.StatusCode
}
]
]
Loading