Implement Slack authentication on iOS
This commit is contained in:
parent
ec8cc533ab
commit
e0fcc3ee09
10 changed files with 419 additions and 81 deletions
|
|
@ -2,6 +2,11 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:burrow.rs?mode=developer</string>
|
||||
<string>webcredentials:burrow.rs?mode=developer</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:burrow.rs?mode=developer</string>
|
||||
<string>webcredentials:burrow.rs?mode=developer</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,29 @@
|
|||
import AuthenticationServices
|
||||
import SwiftUI
|
||||
|
||||
#if !os(macOS)
|
||||
struct BurrowView: View {
|
||||
@Environment(\.webAuthenticationSession)
|
||||
private var webAuthenticationSession
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Networks")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
Spacer()
|
||||
Menu {
|
||||
Button("Hack Club", action: addHackClubNetwork)
|
||||
Button("WireGuard", action: addWireGuardNetwork)
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title)
|
||||
.accessibilityLabel("Add")
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
NetworkCarouselView()
|
||||
Spacer()
|
||||
TunnelStatusView()
|
||||
|
|
@ -11,9 +31,35 @@ struct BurrowView: View {
|
|||
.padding(.bottom)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Networks")
|
||||
.handleOAuth2Callback()
|
||||
}
|
||||
}
|
||||
|
||||
private func addHackClubNetwork() {
|
||||
Task {
|
||||
try await authenticateWithSlack()
|
||||
}
|
||||
}
|
||||
|
||||
private func addWireGuardNetwork() {
|
||||
|
||||
}
|
||||
|
||||
private func authenticateWithSlack() async throws {
|
||||
guard
|
||||
let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"),
|
||||
let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"),
|
||||
let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return }
|
||||
let session = OAuth2.Session(
|
||||
authorizationEndpoint: authorizationEndpoint,
|
||||
tokenEndpoint: tokenEndpoint,
|
||||
redirectURI: redirectURI,
|
||||
scopes: ["openid", "profile"],
|
||||
clientID: "2210535565.6884042183125",
|
||||
clientSecret: "2793c8a5255cae38830934c664eeb62d"
|
||||
)
|
||||
let response = try await session.authorize(webAuthenticationSession)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -24,3 +70,4 @@ struct NetworkView_Previews: PreviewProvider {
|
|||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
|
|
|||
39
Apple/App/NetworkCarouselView.swift
Normal file
39
Apple/App/NetworkCarouselView.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import SwiftUI
|
||||
|
||||
struct NetworkCarouselView: View {
|
||||
var networks: [any Network] = [
|
||||
HackClub(id: "1"),
|
||||
HackClub(id: "2"),
|
||||
WireGuard(id: "4"),
|
||||
HackClub(id: "5"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack {
|
||||
ForEach(networks, id: \.id) { network in
|
||||
NetworkView(network: network)
|
||||
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
|
||||
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
|
||||
content
|
||||
.scaleEffect(1.0 - abs(phase.value) * 0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.scrollClipDisabled()
|
||||
.scrollIndicators(.hidden)
|
||||
.defaultScrollAnchor(.center)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.containerRelativeFrame(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct NetworkCarouselView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NetworkCarouselView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -30,59 +30,9 @@ struct NetworkView<Content: View>: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct AddNetworkView: View {
|
||||
var body: some View {
|
||||
Text("Add Network")
|
||||
.frame(maxWidth: .infinity, minHeight: 175, maxHeight: 175)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(style: .init(lineWidth: 2, dash: [6]))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension NetworkView where Content == AnyView {
|
||||
init(network: any Network) {
|
||||
color = network.backgroundColor
|
||||
content = { AnyView(network.label) }
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkCarouselView: View {
|
||||
var networks: [any Network] = [
|
||||
HackClub(id: "1"),
|
||||
HackClub(id: "2"),
|
||||
WireGuard(id: "4"),
|
||||
HackClub(id: "5"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack {
|
||||
ForEach(networks, id: \.id) { network in
|
||||
NetworkView(network: network)
|
||||
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
|
||||
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
|
||||
content
|
||||
.scaleEffect(1.0 - abs(phase.value) * 0.1)
|
||||
}
|
||||
}
|
||||
AddNetworkView()
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
.scrollIndicators(.hidden)
|
||||
.defaultScrollAnchor(.center)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.containerRelativeFrame(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct NetworkCarouselView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NetworkCarouselView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
280
Apple/App/OAuth2.swift
Normal file
280
Apple/App/OAuth2.swift
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import AuthenticationServices
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
enum OAuth2 {
|
||||
enum Error: Swift.Error {
|
||||
case unknown
|
||||
case invalidAuthorizationURL
|
||||
case invalidCallbackURL
|
||||
case invalidRedirectURI
|
||||
}
|
||||
|
||||
struct Credential {
|
||||
var accessToken: String
|
||||
var refreshToken: String?
|
||||
var expirationDate: Date?
|
||||
}
|
||||
|
||||
struct Session {
|
||||
var authorizationEndpoint: URL
|
||||
var tokenEndpoint: URL
|
||||
var redirectURI: URL
|
||||
var responseType = OAuth2.ResponseType.code
|
||||
var scopes: Set<String>
|
||||
var clientID: String
|
||||
var clientSecret: String
|
||||
|
||||
fileprivate static var queue: [Int: CheckedContinuation<URL, Swift.Error>] = [:]
|
||||
|
||||
fileprivate static func handle(url: URL) {
|
||||
let continuations = queue
|
||||
queue.removeAll()
|
||||
for (_, continuation) in continuations {
|
||||
continuation.resume(returning: url)
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
authorizationEndpoint: URL,
|
||||
tokenEndpoint: URL,
|
||||
redirectURI: URL,
|
||||
scopes: Set<String>,
|
||||
clientID: String,
|
||||
clientSecret: String
|
||||
) {
|
||||
self.authorizationEndpoint = authorizationEndpoint
|
||||
self.tokenEndpoint = tokenEndpoint
|
||||
self.redirectURI = redirectURI
|
||||
self.scopes = scopes
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
}
|
||||
|
||||
private var authorizationURL: URL {
|
||||
get throws {
|
||||
var queryItems: [URLQueryItem] = [
|
||||
.init(name: "client_id", value: clientID),
|
||||
.init(name: "response_type", value: responseType.rawValue),
|
||||
.init(name: "redirect_uri", value: redirectURI.absoluteString),
|
||||
]
|
||||
if !scopes.isEmpty {
|
||||
queryItems.append(.init(name: "scope", value: scopes.joined(separator: ",")))
|
||||
}
|
||||
guard var components = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) else {
|
||||
throw OAuth2.Error.invalidAuthorizationURL
|
||||
}
|
||||
components.queryItems = queryItems
|
||||
guard let authorizationURL = components.url else { throw OAuth2.Error.invalidAuthorizationURL }
|
||||
return authorizationURL
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(callbackURL: URL) async throws -> OAuth2.AccessTokenResponse {
|
||||
switch responseType {
|
||||
case .code:
|
||||
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
|
||||
throw OAuth2.Error.invalidCallbackURL
|
||||
}
|
||||
return try await handle(response: try components.decode(OAuth2.CodeResponse.self))
|
||||
default:
|
||||
throw OAuth2.Error.invalidCallbackURL
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(response: OAuth2.CodeResponse) async throws -> OAuth2.AccessTokenResponse {
|
||||
var components = URLComponents()
|
||||
components.queryItems = [
|
||||
.init(name: "client_id", value: clientID),
|
||||
.init(name: "client_secret", value: clientSecret),
|
||||
.init(name: "grant_type", value: GrantType.authorizationCode.rawValue),
|
||||
.init(name: "code", value: response.code),
|
||||
.init(name: "redirect_uri", value: redirectURI.absoluteString)
|
||||
]
|
||||
let httpBody = Data(components.percentEncodedQuery!.utf8)
|
||||
|
||||
var request = URLRequest(url: tokenEndpoint)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = httpBody
|
||||
|
||||
let session = URLSession(configuration: .ephemeral)
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return try OAuth2.decoder.decode(OAuth2.AccessTokenResponse.self, from: data)
|
||||
}
|
||||
|
||||
func authorize(_ session: WebAuthenticationSession) async throws -> Credential {
|
||||
let authorizationURL = try authorizationURL
|
||||
let callbackURL = try await session.start(
|
||||
url: authorizationURL,
|
||||
redirectURI: redirectURI
|
||||
)
|
||||
return try await handle(callbackURL: callbackURL).credential
|
||||
}
|
||||
}
|
||||
|
||||
private struct CodeResponse: Codable {
|
||||
var code: String
|
||||
var state: String?
|
||||
}
|
||||
|
||||
private struct AccessTokenResponse: Codable {
|
||||
var accessToken: String
|
||||
var tokenType: TokenType
|
||||
var expiresIn: Double?
|
||||
var refreshToken: String?
|
||||
|
||||
var credential: Credential {
|
||||
.init(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expiresIn.map { Date.init(timeIntervalSinceNow: $0) })
|
||||
}
|
||||
}
|
||||
|
||||
enum TokenType: Codable, RawRepresentable {
|
||||
case bearer
|
||||
case unknown(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
self = switch rawValue.lowercased() {
|
||||
case "bearer": .bearer
|
||||
default: .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .bearer: "bearer"
|
||||
case .unknown(let type): type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum GrantType: Codable, RawRepresentable {
|
||||
case authorizationCode
|
||||
case unknown(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
self = switch rawValue.lowercased() {
|
||||
case "authorization_code": .authorizationCode
|
||||
default: .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .authorizationCode: "authorization_code"
|
||||
case .unknown(let type): type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ResponseType: Codable, RawRepresentable {
|
||||
case code
|
||||
case idToken
|
||||
case unknown(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
self = switch rawValue.lowercased() {
|
||||
case "code": .code
|
||||
case "id_token": .idToken
|
||||
default: .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .code: "code"
|
||||
case .idToken: "id_token"
|
||||
case .unknown(let type): type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static var decoder: JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return decoder
|
||||
}
|
||||
|
||||
fileprivate static var encoder: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
extension WebAuthenticationSession {
|
||||
func start(url: URL, redirectURI: URL) async throws -> URL {
|
||||
#if canImport(BrowserEngineKit)
|
||||
if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) {
|
||||
return try await authenticate(
|
||||
using: url,
|
||||
callback: try Self.callback(for: redirectURI),
|
||||
additionalHeaderFields: [:]
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
return try await withThrowingTaskGroup(of: URL.self) { group in
|
||||
group.addTask {
|
||||
return try await authenticate(using: url, callbackURLScheme: redirectURI.scheme ?? "")
|
||||
}
|
||||
|
||||
let id = Int.random(in: 0..<Int.max)
|
||||
group.addTask {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
OAuth2.Session.queue[id] = continuation
|
||||
}
|
||||
}
|
||||
guard let url = try await group.next() else { throw OAuth2.Error.invalidCallbackURL }
|
||||
group.cancelAll()
|
||||
OAuth2.Session.queue[id] = nil
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(BrowserEngineKit)
|
||||
@available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *)
|
||||
fileprivate static func callback(for redirectURI: URL) throws -> ASWebAuthenticationSession.Callback {
|
||||
switch redirectURI.scheme {
|
||||
case "https":
|
||||
guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI }
|
||||
return .https(host: host, path: redirectURI.path)
|
||||
case "http":
|
||||
throw OAuth2.Error.invalidRedirectURI
|
||||
case .some(let scheme):
|
||||
return .customScheme(scheme)
|
||||
case .none:
|
||||
throw OAuth2.Error.invalidRedirectURI
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension View {
|
||||
func handleOAuth2Callback() -> some View {
|
||||
onOpenURL { url in OAuth2.Session.handle(url: url) }
|
||||
}
|
||||
}
|
||||
|
||||
extension URLComponents {
|
||||
fileprivate func decode<T: Decodable>(_ type: T.Type) throws -> T {
|
||||
guard let queryItems else {
|
||||
throw DecodingError.valueNotFound(
|
||||
T.self,
|
||||
.init(codingPath: [], debugDescription: "Missing query items")
|
||||
)
|
||||
}
|
||||
let data = try OAuth2.encoder.encode(try queryItems.values)
|
||||
return try OAuth2.decoder.decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == URLQueryItem {
|
||||
fileprivate var values: [String: String?] {
|
||||
get throws {
|
||||
try Dictionary(map { ($0.name, $0.value) }) { _, _ in
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Duplicate query items"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,16 +4,19 @@ struct TunnelButton: View {
|
|||
@Environment(\.tunnel)
|
||||
var tunnel: any Tunnel
|
||||
|
||||
private var action: Action? { tunnel.action }
|
||||
|
||||
var body: some View {
|
||||
if let action = tunnel.action {
|
||||
Button {
|
||||
Button {
|
||||
if let action {
|
||||
tunnel.perform(action)
|
||||
} label: {
|
||||
Text(action.description)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.buttonStyle(.floating)
|
||||
} label: {
|
||||
Text(action.description)
|
||||
}
|
||||
.disabled(action.isDisabled)
|
||||
.padding(.horizontal)
|
||||
.buttonStyle(.floating)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,12 +43,21 @@ extension TunnelButton {
|
|||
}
|
||||
}
|
||||
|
||||
extension TunnelButton.Action {
|
||||
extension TunnelButton.Action? {
|
||||
var description: LocalizedStringKey {
|
||||
switch self {
|
||||
case .enable: "Enable"
|
||||
case .start: "Start"
|
||||
case .stop: "Stop"
|
||||
case .none: "Start"
|
||||
}
|
||||
}
|
||||
|
||||
var isDisabled: Bool {
|
||||
if case .none = self {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue