Skip to content
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
5 changes: 4 additions & 1 deletion .env.testing
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,7 @@ MIXCLOUD_CLIENT_ID=test
MIXCLOUD_CLIENT_SECRET=test

SHOPIFY_CLIENT_ID=test
SHOPIFY_CLIENT_SECRET=test
SHOPIFY_CLIENT_SECRET=test

LINKEDIN_CLIENT_SECRET=test
LINKEDIN_CLIENT_ID=test
8 changes: 6 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ let package = Package(
.library(name: "ImperialMicrosoft", targets: ["ImperialCore", "ImperialMicrosoft"]),
.library(name: "ImperialMixcloud", targets: ["ImperialCore", "ImperialMixcloud"]),
.library(name: "ImperialShopify", targets: ["ImperialCore", "ImperialShopify"]),
.library(name: "ImperialLinkedIn", targets: ["ImperialCore", "ImperialLinkedIn"]),
.library(
name: "Imperial",
targets: [
Expand All @@ -41,12 +42,13 @@ let package = Package(
"ImperialMicrosoft",
"ImperialMixcloud",
"ImperialShopify",
"ImperialLinkedIn",
]
),
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.114.1"),
.package(url: "https://github.com/vapor/jwt-kit.git", from: "5.1.2"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.117.0"),
.package(url: "https://github.com/vapor/jwt-kit.git", from: "5.2.0"),
],
targets: [
.target(
Expand All @@ -70,6 +72,7 @@ let package = Package(
.target(name: "ImperialMicrosoft", dependencies: ["ImperialCore"], swiftSettings: swiftSettings),
.target(name: "ImperialMixcloud", dependencies: ["ImperialCore"], swiftSettings: swiftSettings),
.target(name: "ImperialShopify", dependencies: ["ImperialCore"], swiftSettings: swiftSettings),
.target(name: "ImperialLinkedIn", dependencies: ["ImperialCore"], swiftSettings: swiftSettings),
.testTarget(
name: "ImperialTests",
dependencies: [
Expand All @@ -87,6 +90,7 @@ let package = Package(
.target(name: "ImperialMicrosoft"),
.target(name: "ImperialMixcloud"),
.target(name: "ImperialShopify"),
.target(name: "ImperialLinkedIn"),
.product(name: "VaporTesting", package: "vapor"),
],
resources: [
Expand Down
96 changes: 96 additions & 0 deletions Sources/ImperialLinkedIn/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# ``ImperialLinkedIn``

Federated Authentication with LinkedIn for Vapor.

## Overview

### LinkedIn setup

1. Register your app on the LinkedIn developer portal
2. Add the product "Sign in with LinkedIn"
3. Set your redirect URI (e.g., `https://yourdomain.com/auth/linkedin/callback`)
4. Set scopes `openid`, `profile` and `email`


### LinkedIn API

[Developer documentation](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2?context=linkedin%2Fconsumer%2Fcontext)


### Expected environment variables

Imperial expects two environment variables:

* `LINKEDIN_CLIENT_ID=your_client_id`
* `LINKEDIN_CLIENT_SECRET=your_client_secret`

### Integration example

```swift
Import Vapor

struct LinkedInUserInfo: Content {
let sub: String
let given_name: String?
let family_name: String?
let picture: String?
let email: String
let email_verified: Bool?
let locale: LinkedInLocale?
}

struct LinkedInLocale: Content {
let country: String?
let language: String?
}

struct LinkedInImperialRouter: RouteCollection {

func boot(routes: RoutesBuilder) throws {

try routes.oAuth(
from: LinkedIn.self,
authenticate: "auth2/linkedin/",
callback: yourCustomCallbackPath,
scope: ["openid","profile","email"],
completion: processLinkedInLogin
)
}

func processLinkedInLogin(request: Request, token: String) async throws -> Response {

let userInfo = try await LinkedIn.getUser(on: request)

// Your own user management implementation
var user = try await User
.query(on: request.db)
.filter(\.$username == userInfo.sub)
.first()

// You might update and save your user data

}

extension LinkedIn {

static func getUser(on request: Request) async throws -> LinkedInUserInfo {
var headers = HTTPHeaders()
headers.bearerAuthorization = try BearerAuthorization(token: request.accessToken)

let userInfoURL: URI = "https://api.linkedin.com/v2/userinfo"
let response = try await request.client.get(userInfoURL, headers: headers)
print(response)
let userInfo = try response.content.decode(LinkedInUserInfo.self)

request.logger.debug("\(userInfo)")

guard response.status == .ok else {
throw Abort(.unauthorized)
}

return userInfo
}
}
```


17 changes: 17 additions & 0 deletions Sources/ImperialLinkedIn/LinkedIn.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@_exported import ImperialCore
import Vapor

public struct LinkedIn: FederatedService {
@discardableResult
public init(
routes: some RoutesBuilder,
authenticate: String,
authenticateCallback: (@Sendable (Request) async throws -> Void)?,
callback: String,
scope: [String] = ["openid", "profile", "email"],
completion: @escaping @Sendable (Request, String) async throws -> some AsyncResponseEncodable
) throws {
try LinkedInRouter(callback: callback, scope: scope, completion: completion)
.configureRoutes(withAuthURL: authenticate, authenticateCallback: authenticateCallback, on: routes)
}
}
24 changes: 24 additions & 0 deletions Sources/ImperialLinkedIn/LinkedInAuth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Vapor

struct LinkedInAuth: FederatedServiceTokens {
static let idEnvKey: String = "LINKEDIN_CLIENT_ID"
static let secretEnvKey: String = "LINKEDIN_CLIENT_SECRET"
let clientID: String
let clientSecret: String

init() throws {
guard
let clientID = Environment.get(Self.idEnvKey)
else {
throw ImperialError.missingEnvVar(Self.idEnvKey)
}
self.clientID = clientID

guard
let clientSecret = Environment.get(Self.secretEnvKey)
else {
throw ImperialError.missingEnvVar(Self.secretEnvKey)
}
self.clientSecret = clientSecret
}
}
19 changes: 19 additions & 0 deletions Sources/ImperialLinkedIn/LinkedInCallbackBody.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Vapor

struct LinkedInCallbackBody: Content {
let grantType: String = "authorization_code"
let code: String
let redirectURI: String
let clientId: String
let clientSecret: String

static let defaultContentType: HTTPMediaType = .urlEncodedForm

enum CodingKeys: String, CodingKey {
case grantType = "grant_type"
case code
case redirectURI = "redirect_uri"
case clientId = "client_id"
case clientSecret = "client_secret"
}
}
52 changes: 52 additions & 0 deletions Sources/ImperialLinkedIn/LinkedInRouter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation
import Vapor

struct LinkedInRouter: FederatedServiceRouter {
let tokens: any FederatedServiceTokens
let callbackCompletion: @Sendable (Request, String) async throws -> any AsyncResponseEncodable
let scope: [String]
let callbackURL: String
let accessTokenURL: String = "https://www.linkedin.com/oauth/v2/accessToken"
let callbackHeaders: HTTPHeaders = {
var headers = HTTPHeaders()
headers.contentType = .urlEncodedForm
return headers
}()

init(
callback: String, scope: [String], completion: @escaping @Sendable (Request, String) async throws -> some AsyncResponseEncodable
) throws {
self.tokens = try LinkedInAuth()
self.callbackURL = callback
self.callbackCompletion = completion
self.scope = scope
}

func authURL(_ request: Request) throws -> String {
var components = URLComponents()
components.scheme = "https"
components.host = "www.linkedin.com"
components.path = "/oauth/v2/authorization"
components.queryItems = [
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "client_id", value: tokens.clientID),
URLQueryItem(name: "redirect_uri", value: callbackURL),
URLQueryItem(name: "scope", value: scope.joined(separator: " ")),
]

guard let url = components.url else {
throw Abort(.internalServerError)
}

return url.absoluteString
}

func callbackBody(with code: String) -> any AsyncResponseEncodable {
LinkedInCallbackBody(
code: code,
redirectURI: callbackURL,
clientId: tokens.clientID,
clientSecret: tokens.clientSecret
)
}
}
21 changes: 21 additions & 0 deletions Tests/ImperialTests/ImperialTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ImperialImgur
import ImperialKeycloak
import ImperialMicrosoft
import ImperialMixcloud
import ImperialLinkedIn
import Testing
import VaporTesting

Expand Down Expand Up @@ -218,6 +219,26 @@ struct ImperialTests {
}
}

@Test("LinkedIn Route")
func linkedInRoute() async throws {
try await withApp(service: LinkedIn.self) { app in
try await app.test(
.GET, authURL,
afterResponse: { res async throws in
#expect(res.status == .seeOther)
}
)

try await app.test(
.GET, "\(callbackURL)?code=123",
afterResponse: { res async throws in
// TODO: test this route
#expect(res.status != .notFound)
}
)
}
}

@Test("Keycloak Route")
func keycloakRoute() async throws {
try await withApp(service: Keycloak.self) { app in
Expand Down