diff --git a/src/JobsJobsJobs/Api/App.fs b/src/JobsJobsJobs/Api/App.fs index ccf4b63..af8bd6e 100644 --- a/src/JobsJobsJobs/Api/App.fs +++ b/src/JobsJobsJobs/Api/App.fs @@ -30,6 +30,7 @@ open Microsoft.Extensions.Configuration open Microsoft.Extensions.Logging open Microsoft.IdentityModel.Tokens open System.Text +open JobsJobsJobs.Domain.SharedTypes /// Configure dependency injection let configureServices (svc : IServiceCollection) = @@ -57,10 +58,11 @@ let configureServices (svc : IServiceCollection) = ValidAudience = "https://noagendacareers.com", ValidIssuer = "https://noagendacareers.com", IssuerSigningKey = SymmetricSecurityKey ( - Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"])))) + Encoding.UTF8.GetBytes (cfg.GetSection "Auth").["ServerSecret"]))) |> ignore svc.AddAuthorization () |> ignore - + svc.Configure (cfg.GetSection "Auth") |> ignore + let dbCfg = cfg.GetSection "Rethink" let log = svcs.GetRequiredService().CreateLogger (nameof Data.Startup) let conn = Data.Startup.createConnection dbCfg log diff --git a/src/JobsJobsJobs/Api/Auth.fs b/src/JobsJobsJobs/Api/Auth.fs index 376d3bc..d1a0a74 100644 --- a/src/JobsJobsJobs/Api/Auth.fs +++ b/src/JobsJobsJobs/Api/Auth.fs @@ -3,16 +3,16 @@ module JobsJobsJobs.Api.Auth open System.Text.Json.Serialization -/// The variables we need from the account information we get from No Agenda Social +/// The variables we need from the account information we get from Mastodon [] type MastodonAccount () = /// The user name (what we store as naUser) [] member val Username = "" with get, set - /// The account name; will be the same as username for local (non-federated) accounts + /// The account name; will generally be the same as username for local accounts, which is all we can verify [] member val AccountName = "" with get, set - /// The user's display name as it currently shows on No Agenda Social + /// The user's display name as it currently shows on Mastodon [] member val DisplayName = "" with get, set /// The user's profile URL @@ -21,25 +21,29 @@ type MastodonAccount () = open FSharp.Control.Tasks -open Microsoft.Extensions.Configuration open Microsoft.Extensions.Logging open System open System.Net.Http open System.Net.Http.Headers open System.Net.Http.Json open System.Text.Json +open JobsJobsJobs.Domain.SharedTypes + +/// HTTP client to use to communication with Mastodon +let private http = new HttpClient() /// Verify the authorization code with Mastodon and get the user's profile -let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task { +let verifyWithMastodon (authCode : string) (inst : MastodonInstance) rtnHost (log : ILogger) = task { - use http = new HttpClient() + // Function to create a URL for the given instance + let apiUrl = sprintf "%s/api/v1/%s" inst.Url - // Use authorization code to get an access token from NAS + // Use authorization code to get an access token from Mastodon use! codeResult = - http.PostAsJsonAsync("https://noagendasocial.com/oauth/token", - {| client_id = cfg.["ClientId"] - client_secret = cfg.["Secret"] - redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"] + http.PostAsJsonAsync($"{inst.Url}/oauth/token", + {| client_id = inst.ClientId + client_secret = inst.Secret + redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized" grant_type = "authorization_code" code = authCode scope = "read" @@ -49,11 +53,10 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : let! responseBytes = codeResult.Content.ReadAsByteArrayAsync () use tokenResponse = JsonSerializer.Deserialize (ReadOnlySpan responseBytes) match tokenResponse with - | null -> - return Error "Could not parse authorization code result" + | null -> return Error "Could not parse authorization code result" | _ -> // Use access token to get profile from NAS - use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"]) + use req = new HttpRequestMessage (HttpMethod.Get, apiUrl "accounts/verify_credentials") req.Headers.Authorization <- AuthenticationHeaderValue ("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ()) use! profileResult = http.SendAsync req @@ -62,19 +65,13 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : | true -> let! profileBytes = profileResult.Content.ReadAsByteArrayAsync () match JsonSerializer.Deserialize(ReadOnlySpan profileBytes) with - | null -> - return Error "Could not parse profile result" - | x when x.Username <> x.AccountName -> - return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}" - | profile -> - return Ok profile - | false -> - return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})" + | null -> return Error "Could not parse profile result" + | profile -> return Ok profile + | false -> return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})" | false -> let! err = codeResult.Content.ReadAsStringAsync () log.LogError $"Could not get token result from Mastodon:\n {err}" return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})" - } @@ -86,7 +83,7 @@ open System.Security.Claims open System.Text /// Create a JSON Web Token for this citizen to use for further requests to this API -let createJwt (citizen : Citizen) (cfg : IConfigurationSection) = +let createJwt (citizen : Citizen) (cfg : AuthOptions) = let tokenHandler = JwtSecurityTokenHandler () let token = @@ -100,8 +97,7 @@ let createJwt (citizen : Citizen) (cfg : IConfigurationSection) = Issuer = "https://noagendacareers.com", Audience = "https://noagendacareers.com", SigningCredentials = SigningCredentials ( - SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]), - SecurityAlgorithms.HmacSha256Signature) + SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature) ) ) tokenHandler.WriteToken token diff --git a/src/JobsJobsJobs/Api/Handlers.fs b/src/JobsJobsJobs/Api/Handlers.fs index bc24dd4..c2f7de3 100644 --- a/src/JobsJobsJobs/Api/Handlers.fs +++ b/src/JobsJobsJobs/Api/Handlers.fs @@ -23,23 +23,23 @@ module Error = /// URL prefixes for the Vue app let vueUrls = [ - "/"; "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile" + "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile" "/so-long"; "/success-story" ] /// Handler that will return a status code 404 and the text "Not Found" let notFound : HttpHandler = fun next ctx -> task { - let fac = ctx.GetService() - let log = fac.CreateLogger("Handler") + let fac = ctx.GetService () + let log = fac.CreateLogger "Handler" + let path = string ctx.Request.Path match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with - | true when vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) -> + | true when path = "/" || vueUrls |> List.exists path.StartsWith -> log.LogInformation "Returning Vue app" return! Vue.app next ctx | _ -> log.LogInformation "Returning 404" - return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next - ctx + return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx } /// Handler that returns a 403 NOT AUTHORIZED response @@ -58,6 +58,7 @@ module Helpers = open NodaTime open Microsoft.Extensions.Configuration + open Microsoft.Extensions.Options open RethinkDb.Driver.Net open System.Security.Claims @@ -67,6 +68,9 @@ module Helpers = /// Get the application configuration from the request context let config (ctx : HttpContext) = ctx.GetService () + /// Get the authorization configuration from the request context + let authConfig (ctx : HttpContext) = (ctx.GetService> ()).Value + /// Get the logger factory from the request context let logger (ctx : HttpContext) = ctx.GetService () @@ -104,46 +108,49 @@ module Helpers = module Citizen = // GET: /api/citizen/log-on/[code] - let logOn authCode : HttpHandler = + let logOn (abbr, authCode) : HttpHandler = fun next ctx -> task { // Step 1 - Verify with Mastodon - let cfg = (config ctx).GetSection "Auth" - let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) + let cfg = authConfig ctx - match! Auth.verifyWithMastodon authCode cfg log with - | Ok account -> - // Step 2 - Find / establish Jobs, Jobs, Jobs account - let now = (clock ctx).GetCurrentInstant () - let dbConn = conn ctx - let! citizen = task { - match! Data.Citizen.findByNaUser account.Username dbConn with - | None -> - let it : Citizen = - { id = CitizenId.create () - naUser = account.Username - displayName = noneIfEmpty account.DisplayName - realName = None - profileUrl = account.Url - joinedOn = now - lastSeenOn = now - } - do! Data.Citizen.add it dbConn - return it - | Some citizen -> - let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now } - do! Data.Citizen.logOnUpdate it dbConn - return it - } + match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with + | Some instance -> + let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) - // Step 3 - Generate JWT - return! - json - { jwt = Auth.createJwt citizen cfg - citizenId = CitizenId.toString citizen.id - name = Citizen.name citizen - } next ctx - | Error err -> - return! RequestErrors.BAD_REQUEST err next ctx + match! Auth.verifyWithMastodon authCode instance cfg.ReturnUrl log with + | Ok account -> + // Step 2 - Find / establish Jobs, Jobs, Jobs account + let now = (clock ctx).GetCurrentInstant () + let dbConn = conn ctx + let! citizen = task { + match! Data.Citizen.findByNaUser account.Username dbConn with + | None -> + let it : Citizen = + { id = CitizenId.create () + naUser = account.Username + displayName = noneIfEmpty account.DisplayName + realName = None + profileUrl = account.Url + joinedOn = now + lastSeenOn = now + } + do! Data.Citizen.add it dbConn + return it + | Some citizen -> + let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now } + do! Data.Citizen.logOnUpdate it dbConn + return it + } + + // Step 3 - Generate JWT + return! + json + { jwt = Auth.createJwt citizen cfg + citizenId = CitizenId.toString citizen.id + name = Citizen.name citizen + } next ctx + | Error err -> return! RequestErrors.BAD_REQUEST err next ctx + | None -> return! Error.notFound next ctx } // GET: /api/citizen/[id] @@ -176,6 +183,33 @@ module Continent = } +/// Handlers for /api/instances routes +[] +module Instances = + + /// Convert a Masotodon instance to the one we use in the API + let private toInstance (inst : MastodonInstance) = + { name = inst.Name + url = inst.Url + abbr = inst.Abbr + clientId = inst.ClientId + } + + // GET: /api/instances + let all : HttpHandler = + fun next ctx -> task { + return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx + } + + // GET: /api/instance/[abbr] + let byAbbr abbr : HttpHandler = + fun next ctx -> task { + match (authConfig ctx).Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with + | Some inst -> return! json (toInstance inst) next ctx + | None -> return! Error.notFound next ctx + } + + /// Handlers for /api/listing[s] routes [] module Listing = @@ -489,12 +523,18 @@ let allEndpoints = [ subRoute "/api" [ subRoute "/citizen" [ GET_HEAD [ - routef "/log-on/%s" Citizen.logOn - routef "/%O" Citizen.get + routef "/log-on/%s/%s" Citizen.logOn + routef "/%O" Citizen.get ] DELETE [ route "" Citizen.delete ] ] GET_HEAD [ route "/continents" Continent.all ] + subRoute "/instance" [ + GET_HEAD [ + route "s" Instances.all + routef "/%s" Instances.byAbbr + ] + ] subRoute "/listing" [ GET_HEAD [ routef "/%O" Listing.get diff --git a/src/JobsJobsJobs/Api/appsettings.json b/src/JobsJobsJobs/Api/appsettings.json index cbdb783..cb388fe 100644 --- a/src/JobsJobsJobs/Api/appsettings.json +++ b/src/JobsJobsJobs/Api/appsettings.json @@ -1,6 +1,24 @@ { "Rethink": { - "Hostname": "localhost", - "Db": "jobsjobsjobs" + }, + "Auth": { + "ReturnHost": "http://localhost:5000", + "Instances": { + "0": { + "Name": "No Agenda Social", + "Url": "https://noagendasocial.com", + "Abbr": "nas" + }, + "1": { + "Name": "ITM Slaves!", + "Url": "https://itmslaves.com", + "Abbr": "itm" + }, + "2": { + "Name": "Liberty Woof", + "Url": "https://libertywoof.com", + "Abbr": "lw" + } + } } } \ No newline at end of file diff --git a/src/JobsJobsJobs/App/src/api/index.ts b/src/JobsJobsJobs/App/src/api/index.ts index 5b2c297..a0374da 100644 --- a/src/JobsJobsJobs/App/src/api/index.ts +++ b/src/JobsJobsJobs/App/src/api/index.ts @@ -2,6 +2,7 @@ import { Citizen, Continent, Count, + Instance, Listing, ListingExpireForm, ListingForm, @@ -100,11 +101,12 @@ export default { /** * Log a citizen on * - * @param code The authorization code from No Agenda Social + * @param abbr The abbreviation of the Mastodon instance that issued the code + * @param code The authorization code from Mastodon * @returns The user result, or an error */ - logOn: async (code : string) : Promise => { - const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" }) + logOn: async (abbr : string, code : string) : Promise => { + const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" }) if (resp.status === 200) return await resp.json() as LogOnSuccess return `Error logging on - ${await resp.text()}` }, @@ -141,6 +143,27 @@ export default { apiResult(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents") }, + /** API functions for instances */ + instances: { + + /** + * Get all Mastodon instances we support + * + * @returns All instances, or an error + */ + all: async () : Promise => + apiResult(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances"), + + /** + * Retrieve a Mastodon instance by its abbreviation + * + * @param abbr The abbreviation of the Mastodon instance to retrieve + * @returns The Mastodon instance (if found), undefined (if not found), or an error string + */ + byAbbr: async (abbr : string) : Promise => + apiResult(await fetch(apiUrl(`instance/${abbr}`), { method: "GET" }), "retrieving Mastodon instance") + }, + /** API functions for job listings */ listings: { diff --git a/src/JobsJobsJobs/App/src/api/types.ts b/src/JobsJobsJobs/App/src/api/types.ts index b9cbd87..ea2ca27 100644 --- a/src/JobsJobsJobs/App/src/api/types.ts +++ b/src/JobsJobsJobs/App/src/api/types.ts @@ -31,6 +31,18 @@ export interface Count { count : number } +/** The Mastodon instance data provided via the Jobs, Jobs, Jobs API */ +export interface Instance { + /** The name of the instance */ + name : string + /** The URL for this instance */ + url : string + /** The abbreviation used in the URL to distinguish this instance's return codes */ + abbr : string + /** The client ID (assigned by the Mastodon server) */ + clientId : string +} + /** A job listing */ export interface Listing { /** The ID of the job listing */ diff --git a/src/JobsJobsJobs/App/src/router/index.ts b/src/JobsJobsJobs/App/src/router/index.ts index 77f39c2..31c6418 100644 --- a/src/JobsJobsJobs/App/src/router/index.ts +++ b/src/JobsJobsJobs/App/src/router/index.ts @@ -53,7 +53,7 @@ const routes: Array = [ component: LogOn }, { - path: "/citizen/authorized", + path: "/citizen/:abbr/authorized", name: "CitizenAuthorized", component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue") }, diff --git a/src/JobsJobsJobs/App/src/store/index.ts b/src/JobsJobsJobs/App/src/store/index.ts index 7e1cda6..379110a 100644 --- a/src/JobsJobsJobs/App/src/store/index.ts +++ b/src/JobsJobsJobs/App/src/store/index.ts @@ -43,8 +43,8 @@ export default createStore({ } }, actions: { - async logOn ({ commit }, code: string) { - const logOnResult = await api.citizen.logOn(code) + async logOn ({ commit }, { abbr, code }) { + const logOnResult = await api.citizen.logOn(abbr, code) if (typeof logOnResult === "string") { commit("setLogOnState", logOnResult) } else { diff --git a/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue b/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue index e355163..9bd05f0 100644 --- a/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue +++ b/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue @@ -7,30 +7,44 @@ article diff --git a/src/JobsJobsJobs/Domain/Domain.fsproj b/src/JobsJobsJobs/Domain/Domain.fsproj index 48c3782..dbac946 100644 --- a/src/JobsJobsJobs/Domain/Domain.fsproj +++ b/src/JobsJobsJobs/Domain/Domain.fsproj @@ -14,6 +14,7 @@ + diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index 30acf2a..cf485f6 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -2,6 +2,7 @@ module JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.Types +open Microsoft.Extensions.Options open NodaTime // fsharplint:disable FieldNames @@ -75,6 +76,45 @@ type Count = { } +/// An instance of a Mastodon server which is configured to work with Jobs, Jobs, Jobs +type MastodonInstance () = + /// The name of the instance + member val Name = "" with get, set + /// The URL for this instance + member val Url = "" with get, set + /// The abbreviation used in the URL to distinguish this instance's return codes + member val Abbr = "" with get, set + /// The client ID (assigned by the Mastodon server) + member val ClientId = "" with get, set + /// The cryptographic secret (provided by the Mastodon server) + member val Secret = "" with get, set + + +/// The authorization options for Jobs, Jobs, Jobs +type AuthOptions () = + /// The return URL for Mastodoon verification + member val ReturnUrl = "" with get, set + /// The secret with which the server signs the JWTs for auth once we've verified with Mastodon + member val ServerSecret = "" with get, set + /// The instances configured for use + member val Instances = Array.empty with get, set + interface IOptions with + override this.Value = this + + +/// The Mastodon instance data provided via the Jobs, Jobs, Jobs API +type Instance = { + /// The name of the instance + name : string + /// The URL for this instance + url : string + /// The abbreviation used in the URL to distinguish this instance's return codes + abbr : string + /// The client ID (assigned by the Mastodon server) + clientId : string + } + + /// The fields required for a skill type SkillForm = { /// The ID of this skill