Authentication & Authorization With Spotify in iOS

Samuel Folledo
The Startup
Published in
12 min readSep 15, 2020

Authenticating through an API like Spotify in order to access and modify a user’s information, music, follows, and so on can be so confusing especially for beginners. Why you may ask? If you have little to no knowledge about dealing with headers, SDK, web APIs, tweaking Xcode’s Build Settings and Info.plist then you are in the right place. My goal in this article is to improve Spotify’s Authorization Guide with better visuals, clearer step by step, actual Swift codes, and more importantly, have my readers level up.

Let’s get it

I will go over refreshable authorization and authentication process in Swift using Spotify’s iOS SDK. This is a task all software engineers should be comfortable with especially for a very secure backend like Spotify.

Prepare Your Environment

I will assume that you registered your app and have a client Identifier from Spotify’s Developer Dashboard. If not, read through Spotify’s iOS SDK Quick Start and App Settings guide. It is important to have appName:// whitelisted as a redirect URIs in your project in Spotify Dashboard in order for Spotify to know how to go back to your app.

Setup Spotify’s iOS SDK in Xcode

Skip this section if you are not planning to use Spotify’s iOS SDK. If you want to make your user’s experience as delightful as possible, download the iOS SDK from GitHub and add it to your Xcode project.

Create a new file, header file, and name it like so AppName-Bridging-Header. Then replace all its contents with#import <SpotifyiOS/SpotifyiOS.h>. Your project navigator should look like the image below. Do not worry about the GoogleService-Info.plist file unless you have pods installed.

Set -ObjC Linker Flag

In order to support the iOS SDK and compile the Objective-C code contained inside the iOS SDK, we will need to add -Objc linker flag. In Xcode, add the linker flag like the image below.

Add Bridging Header

Then we need to add a bridging header next that will allow us to include Objective-C binaries inside our Swift app. We can do that by searching Objective-C Bridging Header in Build Settings and settings its value the same as the name of our header file like the image below

Confirm Info

You will also need to add a new URL Types in Info tab. Give the identifier as your Bundle Id, and the value as your callback without the :// like the image below.

Lastly for security purposes, you will need to open Info.plist as source code and make sure you tell NSAppTransportSecurity that you are supporting the domain, spotify.com. Take this time to also make sure that you have the same changes on your Info.plist as mine that are marked with blue horizontal lines.

Authorization Flows

Spotify comes with four flows to obtain app authorization. Those are:

In this article, we will be following the second option, Authorization Code Flow With Proof Key for Code Exchange (PKCE). According to Spotify, authorization code flow with PKCE is the best option for mobile and desktop applications because it is unsafe to store client secret. It also provides your app with an access token that can be refreshed.

Time to Code

It’s time to finally code. My code mostly came from one of Spotify’s iOS SDK Demo Projects, SPTLoginSampleAppSwift.

From Authorization Code Flow With Proof Key for Code Exchange (PKCE) it is telling us to 1. Create the code verifier challenge then 2. Construct the authorization URI. Fortunately for us, since we are using the Spotify iOS SDK, we can complete those two steps by initiating a session with our session manager. Simply call the following method on button tap.

  @objc func didTapConnect(_ button: UIButton) {
guard let sessionManager = sessionManager else { return }
if #available(iOS 11, *) {
// Use this on iOS 11 and above to take advantage of SFAuthenticationSession
sessionManager.initiateSession(with: scopes, options: .clientOnly)
} else {
// Use this on iOS versions < 11 to use SFSafariViewController
sessionManager.initiateSession(with: scopes, options: .clientOnly, presenting: self)
}
}

The next step is 3. Your app redirects the user to the authorization URI. After initiating a session with session manager, our Spotify app will be launched to get permissions specified in scopes. If user accepts, we will get the code we need to get our access token.

The last thing we need to do is 4. Exchange the authorization code for an access token. To do that we will need to make a POST request to https://accounts.spotify.com/api/token endpoint with the following body (client_id, grant_type, code, redirect_uri) filled out.

///fetch Spotify access token. Use after getting responseTypeCode
func fetchSpotifyToken(completion: @escaping ([String: Any]?, Error?) -> Void) {
let url = URL(string: "https://accounts.spotify.com/api/token")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let spotifyAuthKey = "Basic \((spotifyClientId + ":" + spotifyClientSecretKey).data(using: .utf8)!.base64EncodedString())"
request.allHTTPHeaderFields = ["Authorization": spotifyAuthKey, "Content-Type": "application/x-www-form-urlencoded"]
do {
var requestBodyComponents = URLComponents()
let scopeAsString = stringScopes.joined(separator: " ") //put array to string separated by whitespace
requestBodyComponents.queryItems = [URLQueryItem(name: "client_id", value: spotifyClientId), URLQueryItem(name: "grant_type", value: "authorization_code"), URLQueryItem(name: "code", value: responseTypeCode!), URLQueryItem(name: "redirect_uri", value: redirectUri.absoluteString), URLQueryItem(name: "code_verifier", value: codeVerifier), URLQueryItem(name: "scope", value: scopeAsString),]
request.httpBody = requestBodyComponents.query?.data(using: .utf8)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard
let data = data, // is there data
let response = response as? HTTPURLResponse, // is there HTTP response
(200 ..< 300) ~= response.statusCode, // is statusCode 2XX
error == nil else { // was there no error, otherwise ...
print("Error fetching token \(error?.localizedDescription ?? "")")
return completion(nil, error)
}
let responseObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
print("Access Token Dictionary=", responseObject ?? "")
completion(responseObject, nil)
}
task.resume()
} catch {
print("Error JSON serialization \(error.localizedDescription)")
}
}

According to the guide, the exchange requires code_verifier to be included in the body, however, at the time of this writing, it is not required since we are using the iOS SDK. It may be required for web API authorization and authentication flow.

ViewController.swift before authenticating, playing current music, pause current music.

That’s it!

Congratulations on successfully doing an authorization and authenticating using Spotify iOS SDK. You have leveled up in iOS development 👏🏼👏🏼👏🏼👏🏼👏🏼

Challenge

You may still be a little lost on what is going on, but that’s okay. Repetition is key to grasp these concepts. I highly suggest you do the challenge I have below.

  • Complete this by finishing the flow and access user’s information with the access token and requesting a refreshed access token.
  • Try authorization and authentication using Facebook’s iOS SDK

Full Source Code

For the full source code, click here.

//Constants.swiftimport Foundationlet accessTokenKey = "access-token-key"
let redirectUri = URL(string:"previewtify://")!
let spotifyClientId = "e9d953c9eff4433cb30acf3e4866a68d"
let spotifyClientSecretKey = "e891fd17090d4841afaf88c5730419a9"
/*
Scopes let you specify exactly what types of data your application wants to access, and the set of scopes you pass in your call determines what access permissions the user is asked to grant. For more information, see https://developer.spotify.com/web-api/using-scopes/.
*/
//remove scopes you don't need
let
scopes: SPTScope = [.userReadEmail, .userReadPrivate,
.userReadPlaybackState, .userModifyPlaybackState,
.userReadCurrentlyPlaying, .streaming, .appRemoteControl,
.playlistReadCollaborative, .playlistModifyPublic, .playlistReadPrivate, .playlistModifyPrivate,
.userLibraryModify, .userLibraryRead,
.userTopRead, .userReadPlaybackState, .userReadCurrentlyPlaying,
.userFollowRead, .userFollowModify,]
//remove scopes you don't need
let
stringScopes = ["user-read-email", "user-read-private",
"user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing",
"streaming", "app-remote-control",
"playlist-read-collaborative", "playlist-modify-public", "playlist-read-private", "playlist-modify-private",
"user-library-modify", "user-library-read",
"user-top-read", "user-read-playback-position", "user-read-recently-played",
"user-follow-read", "user-follow-modify",]

SceneDelegate.swift

import UIKitclass SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
lazy var rootViewController = ViewController()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(frame: UIScreen.main.bounds)
window!.makeKeyAndVisible()
window!.windowScene = windowScene
window!.rootViewController = rootViewController
}
//for spotify authorization and authentication flow
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
let parameters = rootViewController.appRemote.authorizationParameters(from: url)
if let code = parameters?["code"] {
rootViewController.responseTypeCode = code
} else if let access_token = parameters?[SPTAppRemoteAccessTokenKey] {
rootViewController.accessToken = access_token
} else if let error_description = parameters?[SPTAppRemoteErrorDescriptionKey] {
print("No access token error =", error_description)
}
}
func sceneDidBecomeActive(_ scene: UIScene) {
if let accessToken = rootViewController.appRemote.connectionParameters.accessToken {
rootViewController.appRemote.connectionParameters.accessToken = accessToken
rootViewController.appRemote.connect()
} else if let accessToken = rootViewController.accessToken {
rootViewController.appRemote.connectionParameters.accessToken = accessToken
rootViewController.appRemote.connect()
}
}
func sceneWillResignActive(_ scene: UIScene) {
if rootViewController.appRemote.isConnected {
rootViewController.appRemote.disconnect()
}
}
}

ViewController.swift

import UIKitclass ViewController: UIViewController {

var responseTypeCode: String? {
didSet {
fetchSpotifyToken { (dictionary, error) in
if
let error = error {
print("Fetching token request error \(error)")
return
}
let accessToken = dictionary!["access_token"] as! String
DispatchQueue.main.async {
self.appRemote.connectionParameters.accessToken = accessToken
self.appRemote.connect()
}
}
}
}
lazy var appRemote: SPTAppRemote = {
let appRemote = SPTAppRemote(configuration: configuration, logLevel: .debug)
appRemote.connectionParameters.accessToken = self.accessToken
appRemote.delegate = self
return
appRemote
}()
var accessToken = UserDefaults.standard.string(forKey: accessTokenKey) {
didSet {
let defaults = UserDefaults.standard
defaults.set(accessToken, forKey: accessTokenKey)
}
}
lazy var configuration: SPTConfiguration = {
let configuration = SPTConfiguration(clientID: spotifyClientId, redirectURL: redirectUri)
// Set the playURI to a non-nil value so that Spotify plays music after authenticating and App Remote can connect
// otherwise another app switch will be required
configuration.playURI = ""
// Set these url's to your backend which contains the secret to exchange for an access token
// You can use the provided ruby script spotify_token_swap.rb for testing purposes
configuration.tokenSwapURL = URL(string: "http://localhost:1234/swap")
configuration.tokenRefreshURL = URL(string: "http://localhost:1234/refresh")
return configuration
}()
lazy var sessionManager: SPTSessionManager? = {
let manager = SPTSessionManager(configuration: configuration, delegate: self)
return manager
}()
private var lastPlayerState: SPTAppRemotePlayerState? // MARK: - Subviews private lazy var connectLabel: UILabel = {
let label = UILabel()
label.text = "Connect your Spotify account"
label.font = UIFont.systemFont(ofSize: 20, weight: .bold)
label.textColor = UIColor(red:(29.0 / 255.0), green:(185.0 / 255.0), blue:(84.0 / 255.0), alpha:1.0)
label.translatesAutoresizingMaskIntoConstraints = false
return
label
}()
private lazy var connectButton: UIButton = {
let button = UIButton()
button.backgroundColor = UIColor(red:(29.0 / 255.0), green:(185.0 / 255.0), blue:(84.0 / 255.0), alpha:1.0)
button.translatesAutoresizingMaskIntoConstraints = false
button.contentEdgeInsets = UIEdgeInsets(top: 11.75, left: 32.0, bottom: 11.75, right: 32.0)
button.layer.cornerRadius = 20.0
button.setTitle("Continue with Spotify", for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 20, weight: .bold)
button.sizeToFit()
button.addTarget(self, action: #selector(didTapConnect(_:)), for: .touchUpInside)
return button
}()
private lazy var disconnectButton: UIButton = {
let button = UIButton()
button.backgroundColor = UIColor(red:(29.0 / 255.0), green:(185.0 / 255.0), blue:(84.0 / 255.0), alpha:1.0)
button.translatesAutoresizingMaskIntoConstraints = false
button.contentEdgeInsets = UIEdgeInsets(top: 11.75, left: 32.0, bottom: 11.75, right: 32.0)
button.layer.cornerRadius = 20.0
button.setTitle("Sign out", for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 20, weight: .bold)
button.sizeToFit()
button.addTarget(self, action: #selector(didTapDisconnect(_:)), for: .touchUpInside)
return button
}()
private lazy var pauseAndPlayButton: UIButton = {
let button = UIButton()
button.addTarget(self, action: #selector(didTapPauseOrPlay), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
button.sizeToFit()
return button
}()
private lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
return imageView
}()
private lazy var trackLabel: UILabel = {
let trackLabel = UILabel()
trackLabel.translatesAutoresizingMaskIntoConstraints = false
trackLabel.textColor = .black
trackLabel.font = UIFont.systemFont(ofSize: 18, weight: .medium)
trackLabel.textAlignment = .center
return trackLabel
}()
//MARK: App Life Cycle

override
func viewDidLoad() {
super.viewDidLoad()
setupViews()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateViewBasedOnConnected()
}
//MARK: Methods
func
setupViews() {
view.backgroundColor = UIColor.white
view.addSubview(connectLabel)
view.addSubview(connectButton)
view.addSubview(disconnectButton)
view.addSubview(imageView)
view.addSubview(trackLabel)
view.addSubview(pauseAndPlayButton)
let constant: CGFloat = 16.0
connectButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
connectButton.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
disconnectButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
disconnectButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -50).isActive = true
connectLabel.centerXAnchor.constraint(equalTo: connectButton.centerXAnchor).isActive = true
connectLabel.bottomAnchor.constraint(equalTo: connectButton.topAnchor, constant: -constant).isActive = true
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
imageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 64).isActive = true
imageView.bottomAnchor.constraint(equalTo: trackLabel.topAnchor, constant: -constant).isActive = true
trackLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
trackLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: constant).isActive = true
trackLabel.bottomAnchor.constraint(equalTo: connectLabel.topAnchor, constant: -constant).isActive = true
pauseAndPlayButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
pauseAndPlayButton.topAnchor.constraint(equalTo: trackLabel.bottomAnchor, constant: constant).isActive = true
pauseAndPlayButton.widthAnchor.constraint(equalToConstant: 50).isActive = true
pauseAndPlayButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
updateViewBasedOnConnected()
}
func update(playerState: SPTAppRemotePlayerState) {
if lastPlayerState?.track.uri != playerState.track.uri {
fetchArtwork(for: playerState.track)
}
lastPlayerState = playerState
trackLabel.text = playerState.track.name
if playerState.isPaused {
pauseAndPlayButton.setImage(UIImage(named: "play"), for: .normal)
} else {
pauseAndPlayButton.setImage(UIImage(named: "pause"), for: .normal)
}
}
func updateViewBasedOnConnected() {
if appRemote.isConnected == true {
connectButton.isHidden = true
disconnectButton.isHidden = false
connectLabel.isHidden = true
imageView.isHidden = false
trackLabel.isHidden = false
pauseAndPlayButton.isHidden = false
} else { //show login
disconnectButton.isHidden = true
connectButton.isHidden = false
connectLabel.isHidden = false
imageView.isHidden = true
trackLabel.isHidden = true
pauseAndPlayButton.isHidden = true
}
}
func fetchArtwork(for track: SPTAppRemoteTrack) {
appRemote.imageAPI?.fetchImage(forItem: track, with: CGSize.zero, callback: { [weak self] (image, error) in
if
let error = error {
print("Error fetching track image: " + error.localizedDescription)
} else if let image = image as? UIImage {
self?.imageView.image = image
}
})
}
func fetchPlayerState() {
appRemote.playerAPI?.getPlayerState({ [weak self] (playerState, error) in
if
let error = error {
print("Error getting player state:" + error.localizedDescription)
} else if let playerState = playerState as? SPTAppRemotePlayerState {
self?.update(playerState: playerState)
}
})
}
// MARK: - Actions @objc func didTapPauseOrPlay(_ button: UIButton) {
if let lastPlayerState = lastPlayerState, lastPlayerState.isPaused {
appRemote.playerAPI?.resume(nil)
} else {
appRemote.playerAPI?.pause(nil)
}
}
@objc func didTapDisconnect(_ button: UIButton) {
if appRemote.isConnected == true {
appRemote.disconnect()
}
}
@objc func didTapConnect(_ button: UIButton) {
guard let sessionManager = sessionManager else { return }
if #available(iOS 11, *) {
// Use this on iOS 11 and above to take advantage of SFAuthenticationSession
sessionManager.initiateSession(with: scopes, options: .clientOnly)
} else {
// Use this on iOS versions < 11 to use SFSafariViewController
sessionManager.initiateSession(with: scopes, options: .clientOnly, presenting: self)
}
}
// MARK: - Private Helpers private func presentAlertController(title: String, message: String, buttonTitle: String) {
DispatchQueue.main.async {
let controller = UIAlertController(title: title, message: message, preferredStyle: .alert)
let action = UIAlertAction(title: buttonTitle, style: .default, handler: nil)
controller.addAction(action)
self.present(controller, animated: true)
}
}
//MARK: POST Request///fetch Spotify access token. Use after getting responseTypeCode
func fetchSpotifyToken(completion: @escaping ([String: Any]?, Error?) -> Void) {
let url = URL(string: "https://accounts.spotify.com/api/token")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let spotifyAuthKey = "Basic \((spotifyClientId + ":" + spotifyClientSecretKey).data(using: .utf8)!.base64EncodedString())"
request.allHTTPHeaderFields = ["Authorization": spotifyAuthKey, "Content-Type": "application/x-www-form-urlencoded"]
do {
var requestBodyComponents = URLComponents()
let scopeAsString = stringScopes.joined(separator: " ") //put array to string separated by whitespace
requestBodyComponents.queryItems = [URLQueryItem(name: "client_id", value: spotifyClientId), URLQueryItem(name: "grant_type", value: "authorization_code"), URLQueryItem(name: "code", value: responseTypeCode!), URLQueryItem(name: "redirect_uri", value: redirectUri.absoluteString), URLQueryItem(name: "code_verifier", value: codeVerifier), URLQueryItem(name: "scope", value: scopeAsString),]
request.httpBody = requestBodyComponents.query?.data(using: .utf8)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard
let data = data, // is there data
let response = response as? HTTPURLResponse, // is there HTTP response
(200 ..< 300) ~= response.statusCode, // is statusCode 2XX
error == nil else { // was there no error, otherwise ...
print("Error fetching token \(error?.localizedDescription ?? "")")
return completion(nil, error)
}
let responseObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
print("Access Token Dictionary=", responseObject ?? "")
completion(responseObject, nil)
}
task.resume()
} catch {
print("Error JSON serialization \(error.localizedDescription)")
}
}
}

// MARK: - SPTAppRemoteDelegate
extension
ViewController: SPTAppRemoteDelegate {
func appRemoteDidEstablishConnection(_ appRemote: SPTAppRemote) {
updateViewBasedOnConnected()
appRemote.playerAPI?.delegate = self
appRemote.playerAPI?.subscribe(toPlayerState: { (success, error) in
if
let error = error {
print("Error subscribing to player state:" + error.localizedDescription)
}
})
fetchPlayerState()
}
func appRemote(_ appRemote: SPTAppRemote, didDisconnectWithError error: Error?) {
updateViewBasedOnConnected()
lastPlayerState = nil
}
func appRemote(_ appRemote: SPTAppRemote, didFailConnectionAttemptWithError error: Error?) {
updateViewBasedOnConnected()
lastPlayerState = nil
}
}

// MARK: - SPTAppRemotePlayerAPIDelegate
extension
ViewController: SPTAppRemotePlayerStateDelegate {
func playerStateDidChange(_ playerState: SPTAppRemotePlayerState) {
debugPrint("Spotify Track name: %@", playerState.track.name)
update(playerState: playerState)
}
}
// MARK: - SPTSessionManagerDelegate
extension
ViewController: SPTSessionManagerDelegate {
func sessionManager(manager: SPTSessionManager, didFailWith error: Error) {
if error.localizedDescription == "The operation couldn’t be completed. (com.spotify.sdk.login error 1.)" {
print("AUTHENTICATE with WEBAPI")
} else {
presentAlertController(title: "Authorization Failed", message: error.localizedDescription, buttonTitle: "Bummer")
}
}

func sessionManager(manager: SPTSessionManager, didRenew session: SPTSession) {
presentAlertController(title: "Session Renewed", message: session.description, buttonTitle: "Sweet")
}
func sessionManager(manager: SPTSessionManager, didInitiate session: SPTSession) {
appRemote.connectionParameters.accessToken = session.accessToken
appRemote.connect()
}
}

Thank you so much for reading my article on how to get authorization and authenticate using a Spotify SDK.

--

--